Provide prev/next buttons to cycle through search results, make search results more visually prominent (#304)

This PR addresses two key pieces of feedback provided on search in #38 

1. Make the search results more visually prominent
2. Make it easier to find the matches by having some way of jumping to next

For the visual prominence facet, I switched from yellow outlines to orange backgrounds.

|Before|After|
|-|-|
|![image](https://user-images.githubusercontent.com/150329/89276105-14746700-d5f8-11ea-9c9d-1dfdfc3bd6d7.png)|![image](https://user-images.githubusercontent.com/150329/89276070-045c8780-d5f8-11ea-8664-9da0af569cec.png)|

For the easy identification portion, I added prev/next buttons to each view, which can also be operated by hitting Enter for next or Shift+Enter for previous.

![Kapture 2020-08-04 at 2 16 57](https://user-images.githubusercontent.com/150329/89276542-a1b7bb80-d5f8-11ea-8642-a172a6561734.gif)
This commit is contained in:
Jamie Wong
2020-08-04 02:20:26 -07:00
committed by GitHub
parent b26cdb5be4
commit 9452aeae82
20 changed files with 831 additions and 364 deletions
+69
View File
@@ -0,0 +1,69 @@
// This file contains a collection of classes which make it easier to perform
// batch rendering of Canvas2D primitives. The advantage of this over just doing
// ctx.beginPath() ... ctx.rect(...) ... ctx.endPath() is that you can construct
// several different batch renderers are the same time, then decide on their
// paint order at the end.
//
// See FlamechartPanZoomView.renderOverlays for an example of how this is used.
export interface TextArgs {
text: string
x: number
y: number
}
export class BatchCanvasTextRenderer {
private argsBatch: TextArgs[] = []
text(args: TextArgs) {
this.argsBatch.push(args)
}
fill(ctx: CanvasRenderingContext2D, color: string) {
if (this.argsBatch.length === 0) return
ctx.fillStyle = color
for (let args of this.argsBatch) {
ctx.fillText(args.text, args.x, args.y)
}
this.argsBatch = []
}
}
export interface RectArgs {
x: number
y: number
w: number
h: number
}
export class BatchCanvasRectRenderer {
private argsBatch: RectArgs[] = []
rect(args: RectArgs) {
this.argsBatch.push(args)
}
private drawPath(ctx: CanvasRenderingContext2D) {
ctx.beginPath()
for (let args of this.argsBatch) {
ctx.rect(args.x, args.y, args.w, args.h)
}
ctx.closePath()
this.argsBatch = []
}
fill(ctx: CanvasRenderingContext2D, color: string) {
if (this.argsBatch.length === 0) return
ctx.fillStyle = color
this.drawPath(ctx)
ctx.fill()
}
stroke(ctx: CanvasRenderingContext2D, color: string, lineWidth: number) {
if (this.argsBatch.length === 0) return
ctx.strokeStyle = color
ctx.lineWidth = lineWidth
this.drawPath(ctx)
ctx.stroke()
}
}
+22 -1
View File
@@ -1,7 +1,7 @@
import {Frame, CallTreeNode} from './profile'
import {lastOf} from './utils'
import {clamp} from './math'
import {clamp, Rect, Vec2} from './math'
export interface FlamechartFrame {
node: CallTreeNode
@@ -90,6 +90,27 @@ export class Flamechart {
return clamp(viewportWidth, minWidth, maxWidth)
}
// Given a desired config-space viewport rectangle, clamp the rectangle so
// that it fits within the given flamechart. This prevents the viewport from
// extending past the bounds of the flamechart or zooming in too far.
getClampedConfigSpaceViewportRect({
configSpaceViewportRect,
renderInverted,
}: {
configSpaceViewportRect: Rect
renderInverted?: boolean
}) {
const configSpaceSize = new Vec2(this.getTotalWeight(), this.getLayers().length)
const width = this.getClampedViewportWidth(configSpaceViewportRect.size.x)
const size = configSpaceViewportRect.size.withX(width)
const origin = Vec2.clamp(
configSpaceViewportRect.origin,
new Vec2(0, renderInverted ? 0 : -1),
Vec2.max(Vec2.zero, configSpaceSize.minus(size).plus(new Vec2(0, 1))),
)
return new Rect(origin, configSpaceViewportRect.size.withX(width))
}
constructor(private source: FlamechartDataSource) {
const stack: FlamechartFrame[] = []
const openFrame = (node: CallTreeNode, value: number) => {
+90
View File
@@ -0,0 +1,90 @@
import {Profile, Frame, CallTreeNode} from './profile'
import {FuzzyMatch, fuzzyMatchStrings} from './fuzzy-find'
import {Flamechart, FlamechartFrame} from './flamechart'
import {Rect, Vec2} from './math'
export enum FlamechartType {
CHRONO_FLAME_CHART,
LEFT_HEAVY_FLAME_GRAPH,
}
// A utility class for storing cached search results to avoid recomputation when
// the search results & profile did not change.
export class ProfileSearchResults {
constructor(readonly profile: Profile, readonly searchQuery: string) {}
private matches: Map<Frame, FuzzyMatch> | null = null
getMatchForFrame(frame: Frame): FuzzyMatch | null {
if (!this.matches) {
this.matches = new Map()
this.profile.forEachFrame(frame => {
const match = fuzzyMatchStrings(frame.name, this.searchQuery)
if (match == null) return
this.matches!.set(frame, match)
})
}
return this.matches.get(frame) || null
}
}
export interface FlamechartSearchMatch {
configSpaceBounds: Rect
node: CallTreeNode
}
interface CachedFlamechartResult {
matches: FlamechartSearchMatch[]
indexForNode: Map<CallTreeNode, number>
}
export class FlamechartSearchResults {
constructor(readonly flamechart: Flamechart, readonly profileResults: ProfileSearchResults) {}
private matches: CachedFlamechartResult | null = null
private getResults(): CachedFlamechartResult {
if (this.matches == null) {
const matches: FlamechartSearchMatch[] = []
const indexForNode = new Map<CallTreeNode, number>()
const visit = (frame: FlamechartFrame, depth: number) => {
const {node} = frame
if (this.profileResults.getMatchForFrame(node.frame)) {
const configSpaceBounds = new Rect(
new Vec2(frame.start, depth),
new Vec2(frame.end - frame.start, 1),
)
indexForNode.set(node, matches.length)
matches.push({configSpaceBounds, node})
}
frame.children.forEach(child => {
visit(child, depth + 1)
})
}
const layers = this.flamechart.getLayers()
if (layers.length > 0) {
layers[0].forEach(frame => visit(frame, 0))
}
this.matches = {matches, indexForNode}
}
return this.matches
}
count(): number {
return this.getResults().matches.length
}
indexOf(node: CallTreeNode): number | null {
const result = this.getResults().indexForNode.get(node)
return result === undefined ? null : result
}
at(index: number): FlamechartSearchMatch {
const matches = this.getResults().matches
if (index < 0 || index >= matches.length) {
throw new Error(`Index ${index} out of bounds in list of ${matches.length} matches.`)
}
return matches[index]
}
}
+21 -10
View File
@@ -116,6 +116,13 @@ export class Profile {
protected frames = new KeyedSet<Frame>()
// Profiles store two call-trees.
//
// The "append order" call tree is the one in which nodes are ordered in
// whatever order they were appended to their parent.
//
// The "grouped" call tree is one in which each node has at most one child per
// frame. Nodes are ordered in decreasing order of weight
protected appendOrderCalltreeRoot = new CallTreeNode(Frame.root, null)
protected groupedCalltreeRoot = new CallTreeNode(Frame.root, null)
@@ -169,6 +176,17 @@ export class Profile {
return this.totalNonIdleWeight
}
// This is private because it should only be called in the ProfileBuilder
// classes. Once a Profile instance has been constructed, it should be treated
// as immutable.
protected sortGroupedCallTree() {
function visit(node: CallTreeNode) {
node.children.sort((a, b) => -(a.getTotalWeight() - b.getTotalWeight()))
node.children.forEach(visit)
}
visit(this.groupedCalltreeRoot)
}
forEachCallGrouped(
openFrame: (node: CallTreeNode, value: number) => void,
closeFrame: (node: CallTreeNode, value: number) => void,
@@ -180,10 +198,7 @@ export class Profile {
let childTime = 0
const children = [...node.children]
children.sort((a, b) => -(a.getTotalWeight() - b.getTotalWeight()))
children.forEach(function (child) {
node.children.forEach(function (child) {
visit(child, start + childTime)
childTime += child.getTotalWeight()
})
@@ -250,12 +265,6 @@ export class Profile {
this.frames.forEach(fn)
}
forEachSample(fn: (sample: CallTreeNode, weight: number) => void) {
for (let i = 0; i < this.samples.length; i++) {
fn(this.samples[i], this.weights[i])
}
}
getProfileWithRecursionFlattened(): Profile {
const builder = new CallTreeProfileBuilder()
@@ -511,6 +520,7 @@ export class StackListProfileBuilder extends Profile {
this.totalWeight,
this.weights.reduce((a, b) => a + b, 0),
)
this.sortGroupedCallTree()
return this
}
}
@@ -651,6 +661,7 @@ export class CallTreeProfileBuilder extends Profile {
if (this.appendOrderStack.length > 1 || this.groupedOrderStack.length > 1) {
throw new Error('Tried to complete profile construction with a non-empty stack')
}
this.sortGroupedCallTree()
return this
}
}
+31
View File
@@ -11,6 +11,10 @@ import {HashParams, getHashParams} from '../lib/hash-params'
import {ProfileGroupState, profileGroup} from './profiles-state'
import {SortMethod, SortField, SortDirection} from '../views/profile-table-view'
import {useSelector} from '../lib/preact-redux'
import {Profile} from '../lib/profile'
import {FlamechartViewState} from './flamechart-view-state'
import {SandwichViewState} from './sandwich-view-state'
import {getProfileToView} from './getters'
export const enum ViewMode {
CHRONO_FLAME_CHART,
@@ -101,3 +105,30 @@ export function useAppSelector<T>(selector: (t: ApplicationState) => T, cacheArg
/* eslint-disable react-hooks/exhaustive-deps */
return useSelector(selector, cacheArgs)
}
export interface ActiveProfileState {
profile: Profile
index: number
chronoViewState: FlamechartViewState
leftHeavyViewState: FlamechartViewState
sandwichViewState: SandwichViewState
}
export function useActiveProfileState(): ActiveProfileState | null {
return useAppSelector(state => {
const {profileGroup} = state
if (!profileGroup) return null
if (profileGroup.indexToView >= profileGroup.profiles.length) return null
const index = profileGroup.indexToView
const profileState = profileGroup.profiles[index]
return {
...profileGroup.profiles[profileGroup.indexToView],
profile: getProfileToView({
profile: profileState.profile,
flattenRecursion: state.flattenRecursion,
}),
index: profileGroup.indexToView,
}
}, [])
}
+19 -33
View File
@@ -1,10 +1,11 @@
import {h} from 'preact'
import {Application, ActiveProfileState} from './application'
import {getProfileToView, getCanvasContext} from '../store/getters'
import {Application} from './application'
import {getCanvasContext} from '../store/getters'
import {actions} from '../store/actions'
import {useActionCreator} from '../lib/preact-redux'
import {memo} from 'preact/compat'
import {useAppSelector} from '../store'
import {useAppSelector, useActiveProfileState} from '../store'
import {ProfileSearchContextProvider} from './search-view'
const {
setLoading,
@@ -24,36 +25,21 @@ export const ApplicationContainer = memo(() => {
[],
)
const activeProfileState: ActiveProfileState | null = useAppSelector(state => {
const {profileGroup} = state
if (!profileGroup) return null
if (profileGroup.indexToView >= profileGroup.profiles.length) return null
const index = profileGroup.indexToView
const profileState = profileGroup.profiles[index]
return {
...profileGroup.profiles[profileGroup.indexToView],
profile: getProfileToView({
profile: profileState.profile,
flattenRecursion: state.flattenRecursion,
}),
index: profileGroup.indexToView,
}
}, [])
return (
<Application
activeProfileState={activeProfileState}
canvasContext={canvasContext}
setGLCanvas={useActionCreator(setGLCanvas, [])}
setLoading={useActionCreator(setLoading, [])}
setError={useActionCreator(setError, [])}
setProfileGroup={useActionCreator(setProfileGroup, [])}
setDragActive={useActionCreator(setDragActive, [])}
setViewMode={useActionCreator(setViewMode, [])}
setFlattenRecursion={useActionCreator(setFlattenRecursion, [])}
setProfileIndexToView={useActionCreator(setProfileIndexToView, [])}
{...appState}
/>
<ProfileSearchContextProvider>
<Application
activeProfileState={useActiveProfileState()}
canvasContext={canvasContext}
setGLCanvas={useActionCreator(setGLCanvas, [])}
setLoading={useActionCreator(setLoading, [])}
setError={useActionCreator(setError, [])}
setProfileGroup={useActionCreator(setProfileGroup, [])}
setDragActive={useActionCreator(setDragActive, [])}
setViewMode={useActionCreator(setViewMode, [])}
setFlattenRecursion={useActionCreator(setFlattenRecursion, [])}
setProfileIndexToView={useActionCreator(setProfileIndexToView, [])}
{...appState}
/>
</ProfileSearchContextProvider>
)
})
+2 -12
View File
@@ -2,16 +2,14 @@ import {h} from 'preact'
import {StyleSheet, css} from 'aphrodite'
import {FileSystemDirectoryEntry} from '../import/file-system-entry'
import {Profile, ProfileGroup} from '../lib/profile'
import {ProfileGroup} from '../lib/profile'
import {FontFamily, FontSize, Colors, Duration} from './style'
import {importEmscriptenSymbolMap} from '../lib/emscripten'
import {SandwichViewContainer} from './sandwich-view'
import {saveToFile} from '../lib/file-format'
import {ApplicationState, ViewMode, canUseXHR} from '../store'
import {ApplicationState, ViewMode, canUseXHR, ActiveProfileState} from '../store'
import {StatelessComponent} from '../lib/typed-redux'
import {LeftHeavyFlamechartView, ChronoFlamechartView} from './flamechart-view-container'
import {SandwichViewState} from '../store/sandwich-view-state'
import {FlamechartViewState} from '../store/flamechart-view-state'
import {CanvasContext} from '../gl/canvas-context'
import {Graphics} from '../gl/graphics'
import {Toolbar} from './toolbar'
@@ -131,14 +129,6 @@ export class GLCanvas extends StatelessComponent<GLCanvasProps> {
}
}
export interface ActiveProfileState {
profile: Profile
index: number
chronoViewState: FlamechartViewState
leftHeavyViewState: FlamechartViewState
sandwichViewState: SandwichViewState
}
export type ApplicationProps = ApplicationState & {
setGLCanvas: (canvas: HTMLCanvasElement | null) => void
setLoading: (loading: boolean) => void
+2 -7
View File
@@ -13,7 +13,7 @@ import {
getFrameToColorBucket,
} from '../store/getters'
import {FlamechartID} from '../store/flamechart-view-state'
import {FlamechartWrapper, useDummySearchProps} from './flamechart-wrapper'
import {FlamechartWrapper} from './flamechart-wrapper'
import {useAppSelector} from '../store'
import {h} from 'preact'
import {memo} from 'preact/compat'
@@ -78,14 +78,9 @@ export const CalleeFlamegraphView = memo((ownProps: FlamechartViewContainerProps
canvasContext={canvasContext}
getCSSColorForFrame={getCSSColorForFrame}
{...useFlamechartSetters(FlamechartID.SANDWICH_CALLEES, index)}
{...callerCallee.calleeFlamegraph}
// This overrides the setSelectedNode specified in useFlamechartSettesr
setSelectedNode={noop}
{...callerCallee.calleeFlamegraph}
/*
* TODO(jlfwong): When implementing search for the sandwich views,
* change these flags
* */
{...useDummySearchProps()}
/>
)
})
+82 -96
View File
@@ -1,5 +1,5 @@
import {Rect, AffineTransform, Vec2, clamp} from '../lib/math'
import {CallTreeNode, Frame} from '../lib/profile'
import {CallTreeNode} from '../lib/profile'
import {Flamechart, FlamechartFrame} from '../lib/flamechart'
import {CanvasContext} from '../gl/canvas-context'
import {FlamechartRenderer} from '../gl/flamechart-renderer'
@@ -13,8 +13,8 @@ import {
import {style} from './flamechart-style'
import {h, Component} from 'preact'
import {css} from 'aphrodite'
import {memoizeByReference} from '../lib/utils'
import {FuzzyMatch, fuzzyMatchStrings} from '../lib/fuzzy-find'
import {ProfileSearchResults} from '../lib/profile-search'
import {BatchCanvasTextRenderer, BatchCanvasRectRenderer} from '../lib/canvas-2d-batch-renderers'
interface FlamechartFrameLabel {
configSpaceBounds: Rect
@@ -56,8 +56,7 @@ export interface FlamechartPanZoomViewProps {
logicalSpaceViewportSize: Vec2
setLogicalSpaceViewportSize: (size: Vec2) => void
searchIsActive: boolean
searchQuery: string
searchResults: ProfileSearchResults | null
}
export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps, {}> {
@@ -173,24 +172,6 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
ctx.clearRect(0, 0, physicalViewSize.x, physicalViewSize.y)
if (this.hoveredLabel) {
let color = Colors.DARK_GRAY
if (this.props.selectedNode === this.hoveredLabel.node) {
color = Colors.DARK_BLUE
}
ctx.lineWidth = 2 * devicePixelRatio
ctx.strokeStyle = color
const physicalViewBounds = configToPhysical.transformRect(this.hoveredLabel.configSpaceBounds)
ctx.strokeRect(
Math.round(physicalViewBounds.left()),
Math.round(physicalViewBounds.top()),
Math.round(Math.max(0, physicalViewBounds.width())),
Math.round(Math.max(0, physicalViewBounds.height())),
)
}
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${FontFamily.MONOSPACE}`
ctx.textBaseline = 'alphabetic'
@@ -201,14 +182,12 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
const LABEL_PADDING_PX = 5 * window.devicePixelRatio
const memoizedFuzzyMatch = memoizeByReference((frame: Frame): FuzzyMatch | null => {
return fuzzyMatchStrings(frame.name, this.props.searchQuery)
})
const frameMatchesSearchQuery = (frame: Frame): FuzzyMatch | null => {
if (!this.props.searchIsActive) return null
if (this.props.searchQuery.length === 0) return null
return memoizedFuzzyMatch(frame)
}
const labelBatch = new BatchCanvasTextRenderer()
const fadedLabelBatch = new BatchCanvasTextRenderer()
const matchedTextHighlightBatch = new BatchCanvasRectRenderer()
const directlySelectedOutlineBatch = new BatchCanvasRectRenderer()
const indirectlySelectedOutlineBatch = new BatchCanvasRectRenderer()
const matchedFrameBatch = new BatchCanvasRectRenderer()
const renderFrameLabelAndChildren = (frame: FlamechartFrame, depth = 0) => {
const width = frame.end - frame.start
@@ -244,7 +223,7 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
}
if (physicalLabelBounds.width() > minWidthToRender) {
const match = frameMatchesSearchQuery(frame.node.frame)
const match = this.props.searchResults?.getMatchForFrame(frame.node.frame)
const trimmedText = trimTextMid(
ctx,
@@ -262,44 +241,40 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
// actually do the highlighting.
let lastEndIndex = 0
let left = physicalLabelBounds.left() + LABEL_PADDING_PX
ctx.fillStyle = Colors.YELLOW
ctx.beginPath()
const padding = (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2 - 2
for (let [startIndex, endIndex] of rangesToHighlightInTrimmedText) {
left += ctx.measureText(trimmedText.trimmedString.substring(lastEndIndex, startIndex))
.width
const highlightWidth = ctx.measureText(
trimmedText.trimmedString.substring(startIndex, endIndex),
).width
ctx.rect(
left,
physicalLabelBounds.top() + padding,
highlightWidth,
physicalViewSpaceFrameHeight - 2 * padding,
left += cachedMeasureTextWidth(
ctx,
trimmedText.trimmedString.substring(lastEndIndex, startIndex),
)
const highlightWidth = cachedMeasureTextWidth(
ctx,
trimmedText.trimmedString.substring(startIndex, endIndex),
)
matchedTextHighlightBatch.rect({
x: left,
y: physicalLabelBounds.top() + padding,
w: highlightWidth,
h: physicalViewSpaceFrameHeight - 2 * padding,
})
left += highlightWidth
lastEndIndex = endIndex
}
ctx.fill()
}
// Note that this is specifying the position of the starting text
// baseline.
if (this.props.searchIsActive && this.props.searchQuery.length > 0 && !match) {
ctx.fillStyle = Colors.LIGHT_GRAY
} else {
ctx.fillStyle = Colors.DARK_GRAY
}
ctx.fillText(
trimmedText.trimmedString,
physicalLabelBounds.left() + LABEL_PADDING_PX,
Math.round(
const batch = this.props.searchResults != null && !match ? fadedLabelBatch : labelBatch
batch.text({
text: trimmedText.trimmedString,
// This is specifying the position of the starting text baseline.
x: physicalLabelBounds.left() + LABEL_PADDING_PX,
y: Math.round(
physicalLabelBounds.bottom() -
(physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2,
),
)
})
}
}
for (let child of frame.children) {
@@ -307,19 +282,14 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
}
}
for (let frame of this.props.flamechart.getLayers()[0] || []) {
renderFrameLabelAndChildren(frame)
}
const frameOutlineWidth = 2 * window.devicePixelRatio
ctx.strokeStyle = Colors.PALE_DARK_BLUE
ctx.lineWidth = frameOutlineWidth
const minConfigSpaceWidthToRenderOutline = (
configToPhysical.inverseTransformVector(new Vec2(1, 0)) || new Vec2(0, 0)
).x
const renderSpecialFrameOutlines = (frame: FlamechartFrame, depth = 0) => {
if (!this.props.selectedNode && !this.props.searchIsActive) return
if (!this.props.selectedNode && this.props.searchResults == null) return
const width = frame.end - frame.start
const y = this.props.renderInverted ? this.configSpaceSize().y - 1 - depth : depth
const configSpaceBounds = new Rect(new Vec2(frame.start, y), new Vec2(width, 1))
@@ -330,49 +300,68 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
if (configSpaceBounds.top() > this.props.configSpaceViewportRect.bottom()) return
if (configSpaceBounds.hasIntersectionWith(this.props.configSpaceViewportRect)) {
let outlineColor: string | null = null
if (this.props.searchResults?.getMatchForFrame(frame.node.frame)) {
const physicalRectBounds = configToPhysical.transformRect(configSpaceBounds)
matchedFrameBatch.rect({
x: Math.round(physicalRectBounds.left() + frameOutlineWidth / 2),
y: Math.round(physicalRectBounds.top() + frameOutlineWidth / 2),
w: Math.round(Math.max(0, physicalRectBounds.width() - frameOutlineWidth)),
h: Math.round(Math.max(0, physicalRectBounds.height() - frameOutlineWidth)),
})
}
if (this.props.selectedNode != null && frame.node.frame === this.props.selectedNode.frame) {
if (frame.node === this.props.selectedNode) {
outlineColor = Colors.DARK_BLUE
} else if (ctx.strokeStyle !== Colors.PALE_DARK_BLUE) {
outlineColor = Colors.PALE_DARK_BLUE
}
} else {
if (frameMatchesSearchQuery(frame.node.frame)) {
outlineColor = Colors.YELLOW
}
}
let batch =
frame.node === this.props.selectedNode
? directlySelectedOutlineBatch
: indirectlySelectedOutlineBatch
if (outlineColor != null) {
if (ctx.strokeStyle !== outlineColor) {
// If the outline color changed, stroke the existing path
// constructed by previous ctx.rect calls, then update the stroke
// style before drawing the next one.
ctx.stroke()
ctx.beginPath()
ctx.strokeStyle = outlineColor
}
const physicalRectBounds = configToPhysical.transformRect(configSpaceBounds)
ctx.rect(
Math.round(physicalRectBounds.left() + 1 + frameOutlineWidth / 2),
Math.round(physicalRectBounds.top() + 1 + frameOutlineWidth / 2),
Math.round(Math.max(0, physicalRectBounds.width() - 2 - frameOutlineWidth)),
Math.round(Math.max(0, physicalRectBounds.height() - 2 - frameOutlineWidth)),
)
batch.rect({
x: Math.round(physicalRectBounds.left() + 1 + frameOutlineWidth / 2),
y: Math.round(physicalRectBounds.top() + 1 + frameOutlineWidth / 2),
w: Math.round(Math.max(0, physicalRectBounds.width() - 2 - frameOutlineWidth)),
h: Math.round(Math.max(0, physicalRectBounds.height() - 2 - frameOutlineWidth)),
})
}
}
for (let child of frame.children) {
renderSpecialFrameOutlines(child, depth + 1)
}
}
ctx.beginPath()
for (let frame of this.props.flamechart.getLayers()[0] || []) {
renderSpecialFrameOutlines(frame)
}
ctx.stroke()
for (let frame of this.props.flamechart.getLayers()[0] || []) {
renderFrameLabelAndChildren(frame)
}
matchedFrameBatch.fill(ctx, Colors.ORANGE)
matchedTextHighlightBatch.fill(ctx, Colors.YELLOW)
fadedLabelBatch.fill(ctx, Colors.LIGHT_GRAY)
labelBatch.fill(ctx, Colors.BLACK)
indirectlySelectedOutlineBatch.stroke(ctx, Colors.PALE_DARK_BLUE, frameOutlineWidth)
directlySelectedOutlineBatch.stroke(ctx, Colors.DARK_BLUE, frameOutlineWidth)
if (this.hoveredLabel) {
let color = Colors.DARK_GRAY
if (this.props.selectedNode === this.hoveredLabel.node) {
color = Colors.DARK_BLUE
}
ctx.lineWidth = 2 * devicePixelRatio
ctx.strokeStyle = color
const physicalViewBounds = configToPhysical.transformRect(this.hoveredLabel.configSpaceBounds)
ctx.strokeRect(
Math.round(physicalViewBounds.left()),
Math.round(physicalViewBounds.top()),
Math.round(Math.max(0, physicalViewBounds.width())),
Math.round(Math.max(0, physicalViewBounds.height())),
)
}
this.renderTimeIndicators()
}
@@ -754,10 +743,7 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
if (this.props.flamechart !== nextProps.flamechart) {
this.hoveredLabel = null
this.renderCanvas()
} else if (
this.props.searchQuery !== nextProps.searchQuery ||
this.props.searchIsActive !== nextProps.searchIsActive
) {
} else if (this.props.searchResults !== nextProps.searchResults) {
this.renderCanvas()
} else if (this.props.selectedNode !== nextProps.selectedNode) {
this.renderCanvas()
+151
View File
@@ -0,0 +1,151 @@
import {memo} from 'preact/compat'
import {useContext, useMemo, useCallback} from 'preact/hooks'
import {SearchView, ProfileSearchContext} from './search-view'
import {
FlamechartSearchMatch,
FlamechartSearchResults,
ProfileSearchResults,
} from '../lib/profile-search'
import {Rect, Vec2} from '../lib/math'
import {h, createContext, ComponentChildren} from 'preact'
import {Flamechart} from '../lib/flamechart'
import {CallTreeNode} from '../lib/profile'
export const FlamechartSearchContext = createContext<FlamechartSearchData | null>(null)
export interface FlamechartSearchProps {
flamechart: Flamechart
selectedNode: CallTreeNode | null
setSelectedNode: (node: CallTreeNode | null) => void
configSpaceViewportRect: Rect
setConfigSpaceViewportRect: (rect: Rect) => void
children: ComponentChildren
}
interface FlamechartSearchData {
results: FlamechartSearchResults | null
flamechart: Flamechart
selectedNode: CallTreeNode | null
setSelectedNode: (node: CallTreeNode | null) => void
configSpaceViewportRect: Rect
setConfigSpaceViewportRect: (rect: Rect) => void
}
export const FlamechartSearchContextProvider = ({
flamechart,
selectedNode,
setSelectedNode,
configSpaceViewportRect,
setConfigSpaceViewportRect,
children,
}: FlamechartSearchProps) => {
const profileSearchResults: ProfileSearchResults | null = useContext(ProfileSearchContext)
const flamechartSearchResults: FlamechartSearchResults | null = useMemo(() => {
if (profileSearchResults == null) {
return null
}
return new FlamechartSearchResults(flamechart, profileSearchResults)
}, [flamechart, profileSearchResults])
return (
<FlamechartSearchContext.Provider
value={{
results: flamechartSearchResults,
flamechart,
selectedNode,
setSelectedNode,
configSpaceViewportRect,
setConfigSpaceViewportRect,
}}
>
{children}
</FlamechartSearchContext.Provider>
)
}
export const FlamechartSearchView = memo(() => {
const flamechartData = useContext(FlamechartSearchContext)
// TODO(jlfwong): This pattern is pretty gross, but I really don't want values
// that can be undefined or null.
const searchResults = flamechartData == null ? null : flamechartData.results
const selectedNode = flamechartData == null ? null : flamechartData.selectedNode
const setSelectedNode = flamechartData == null ? null : flamechartData.setSelectedNode
const configSpaceViewportRect =
flamechartData == null ? null : flamechartData.configSpaceViewportRect
const setConfigSpaceViewportRect =
flamechartData == null ? null : flamechartData.setConfigSpaceViewportRect
const flamechart = flamechartData == null ? null : flamechartData.flamechart
const numResults = searchResults == null ? null : searchResults.count()
const resultIndex: number | null = useMemo(() => {
if (searchResults == null) return null
if (selectedNode == null) return null
return searchResults.indexOf(selectedNode)
}, [searchResults, selectedNode])
const selectAndZoomToMatch = useCallback(
(match: FlamechartSearchMatch) => {
if (!setSelectedNode) return
if (!flamechart) return
if (!configSpaceViewportRect) return
if (!setConfigSpaceViewportRect) return
// After the node is selected, we want to set the viewport so that the new
// node can be seen clearly.
//
// TODO(jlfwong): The lack of animation here can be kind of jarring. It
// would be nice to have some easier way for people to orient themselves
// after the viewport shifted.
const configSpaceResultBounds = match.configSpaceBounds
const viewportRect = new Rect(
configSpaceResultBounds.origin.minus(new Vec2(0, 1)),
configSpaceResultBounds.size.withY(configSpaceViewportRect.height()),
)
setSelectedNode(match.node)
setConfigSpaceViewportRect(
flamechart.getClampedConfigSpaceViewportRect({configSpaceViewportRect: viewportRect}),
)
},
[configSpaceViewportRect, setConfigSpaceViewportRect, setSelectedNode, flamechart],
)
const {selectPrev, selectNext} = useMemo(() => {
if (numResults == null || numResults === 0 || searchResults == null) {
return {selectPrev: () => {}, selectNext: () => {}}
}
return {
selectPrev: () => {
if (!searchResults?.at) return
if (numResults == null || numResults === 0) return
let index = resultIndex == null ? numResults - 1 : resultIndex - 1
if (index < 0) index = numResults - 1
const result = searchResults.at(index)
selectAndZoomToMatch(result)
},
selectNext: () => {
if (!searchResults?.at) return
if (numResults == null || numResults === 0) return
let index = resultIndex == null ? 0 : resultIndex + 1
if (index >= numResults) index = 0
const result = searchResults.at(index)
selectAndZoomToMatch(result)
},
}
}, [numResults, resultIndex, searchResults, searchResults?.at, selectAndZoomToMatch])
return (
<SearchView
resultIndex={resultIndex}
numResults={numResults}
selectPrev={selectPrev}
selectNext={selectNext}
/>
)
})
+38 -36
View File
@@ -14,12 +14,11 @@ import {
createGetCSSColorForFrame,
getFrameToColorBucket,
} from '../store/getters'
import {ActiveProfileState} from './application'
import {Vec2, Rect} from '../lib/math'
import {actions} from '../store/actions'
import {memo} from 'preact/compat'
import {useAppSelector} from '../store'
import {SearchViewProps} from './search-view'
import {ActiveProfileState} from '../store'
import {FlamechartSearchContextProvider} from './flamechart-search-view'
interface FlamechartSetters {
setLogicalSpaceViewportSize: (logicalSpaceViewportSize: Vec2) => void
@@ -66,24 +65,9 @@ export type FlamechartViewProps = {
flamechartRenderer: FlamechartRenderer
renderInverted: boolean
getCSSColorForFrame: (frame: Frame) => string
searchIsActive: boolean
searchQuery: string
setSearchQuery: (query: string) => void
setSearchIsActive: (active: boolean) => void
} & FlamechartSetters &
FlamechartViewState
const {setSearchQuery, setSearchIsActive} = actions
function useSearchViewProps(): SearchViewProps {
return {
searchIsActive: useAppSelector(state => state.searchIsActive, []),
setSearchQuery: useActionCreator(setSearchQuery, []),
searchQuery: useAppSelector(state => state.searchQuery, []),
setSearchIsActive: useActionCreator(setSearchIsActive, []),
}
}
export const getChronoViewFlamechart = memoizeByShallowEquality(
({
profile,
@@ -143,17 +127,26 @@ export const ChronoFlamechartView = memo((props: FlamechartViewContainerProps) =
flamechart,
})
const setters = useFlamechartSetters(FlamechartID.CHRONO, index)
return (
<FlamechartView
renderInverted={false}
<FlamechartSearchContextProvider
flamechart={flamechart}
flamechartRenderer={flamechartRenderer}
canvasContext={canvasContext}
getCSSColorForFrame={getCSSColorForFrame}
{...useFlamechartSetters(FlamechartID.CHRONO, index)}
{...useSearchViewProps()}
{...chronoViewState}
/>
selectedNode={chronoViewState.selectedNode}
setSelectedNode={setters.setSelectedNode}
configSpaceViewportRect={chronoViewState.configSpaceViewportRect}
setConfigSpaceViewportRect={setters.setConfigSpaceViewportRect}
>
<FlamechartView
renderInverted={false}
flamechart={flamechart}
flamechartRenderer={flamechartRenderer}
canvasContext={canvasContext}
getCSSColorForFrame={getCSSColorForFrame}
{...chronoViewState}
{...setters}
/>
</FlamechartSearchContextProvider>
)
})
@@ -195,16 +188,25 @@ export const LeftHeavyFlamechartView = memo((ownProps: FlamechartViewContainerPr
flamechart,
})
const setters = useFlamechartSetters(FlamechartID.LEFT_HEAVY, index)
return (
<FlamechartView
renderInverted={false}
<FlamechartSearchContextProvider
flamechart={flamechart}
flamechartRenderer={flamechartRenderer}
canvasContext={canvasContext}
getCSSColorForFrame={getCSSColorForFrame}
{...useFlamechartSetters(FlamechartID.LEFT_HEAVY, index)}
{...useSearchViewProps()}
{...leftHeavyViewState}
/>
selectedNode={leftHeavyViewState.selectedNode}
setSelectedNode={setters.setSelectedNode}
configSpaceViewportRect={leftHeavyViewState.configSpaceViewportRect}
setConfigSpaceViewportRect={setters.setConfigSpaceViewportRect}
>
<FlamechartView
renderInverted={false}
flamechart={flamechart}
flamechartRenderer={flamechartRenderer}
canvasContext={canvasContext}
getCSSColorForFrame={getCSSColorForFrame}
{...leftHeavyViewState}
{...setters}
/>
</FlamechartSearchContextProvider>
)
})
+25 -24
View File
@@ -1,4 +1,4 @@
import {h} from 'preact'
import {h, Fragment} from 'preact'
import {css} from 'aphrodite'
import {CallTreeNode} from '../lib/profile'
@@ -14,7 +14,8 @@ import {FlamechartPanZoomView} from './flamechart-pan-zoom-view'
import {Hovertip} from './hovertip'
import {FlamechartViewProps} from './flamechart-view-container'
import {StatelessComponent} from '../lib/typed-redux'
import {SearchView} from './search-view'
import {ProfileSearchContext} from './search-view'
import {FlamechartSearchView} from './flamechart-search-view'
export class FlamechartView extends StatelessComponent<FlamechartViewProps> {
private configSpaceSize() {
@@ -102,28 +103,28 @@ export class FlamechartView extends StatelessComponent<FlamechartViewProps> {
canvasContext={this.props.canvasContext}
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
/>
<FlamechartPanZoomView
canvasContext={this.props.canvasContext}
flamechart={this.props.flamechart}
flamechartRenderer={this.props.flamechartRenderer}
renderInverted={false}
onNodeHover={this.onNodeHover}
onNodeSelect={this.onNodeClick}
selectedNode={this.props.selectedNode}
transformViewport={this.transformViewport}
configSpaceViewportRect={this.props.configSpaceViewportRect}
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
setLogicalSpaceViewportSize={this.setLogicalSpaceViewportSize}
searchQuery={this.props.searchQuery}
searchIsActive={this.props.searchIsActive}
/>
<SearchView
searchQuery={this.props.searchQuery}
searchIsActive={this.props.searchIsActive}
setSearchQuery={this.props.setSearchQuery}
setSearchIsActive={this.props.setSearchIsActive}
/>
<ProfileSearchContext.Consumer>
{searchResults => (
<Fragment>
<FlamechartPanZoomView
canvasContext={this.props.canvasContext}
flamechart={this.props.flamechart}
flamechartRenderer={this.props.flamechartRenderer}
renderInverted={false}
onNodeHover={this.onNodeHover}
onNodeSelect={this.onNodeClick}
selectedNode={this.props.selectedNode}
transformViewport={this.transformViewport}
configSpaceViewportRect={this.props.configSpaceViewportRect}
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
setLogicalSpaceViewportSize={this.setLogicalSpaceViewportSize}
searchResults={searchResults}
/>
<FlamechartSearchView />
</Fragment>
)}
</ProfileSearchContext.Consumer>
{this.renderTooltip()}
{this.props.selectedNode && (
<FlamechartDetailView
+5 -22
View File
@@ -8,30 +8,14 @@ import {noop, formatPercent} from '../lib/utils'
import {Hovertip} from './hovertip'
import {FlamechartViewProps} from './flamechart-view-container'
import {StatelessComponent} from '../lib/typed-redux'
import {useCallback} from 'preact/hooks'
import {SearchViewProps} from './search-view'
export function useDummySearchProps(): SearchViewProps {
return {
searchIsActive: false,
searchQuery: '',
setSearchQuery: useCallback((q: string) => {}, []),
setSearchIsActive: useCallback((v: boolean) => {}, []),
}
}
export class FlamechartWrapper extends StatelessComponent<FlamechartViewProps> {
private clampViewportToFlamegraph(viewportRect: Rect) {
const {flamechart, renderInverted} = this.props
const configSpaceSize = new Vec2(flamechart.getTotalWeight(), flamechart.getLayers().length)
const width = this.props.flamechart.getClampedViewportWidth(viewportRect.size.x)
const size = viewportRect.size.withX(width)
const origin = Vec2.clamp(
viewportRect.origin,
new Vec2(0, renderInverted ? 0 : -1),
Vec2.max(Vec2.zero, configSpaceSize.minus(size).plus(new Vec2(0, 1))),
)
return new Rect(origin, viewportRect.size.withX(width))
return flamechart.getClampedConfigSpaceViewportRect({
configSpaceViewportRect: viewportRect,
renderInverted,
})
}
private setConfigSpaceViewportRect = (configSpaceViewportRect: Rect) => {
this.props.setConfigSpaceViewportRect(this.clampViewportToFlamegraph(configSpaceViewportRect))
@@ -95,8 +79,7 @@ export class FlamechartWrapper extends StatelessComponent<FlamechartViewProps> {
renderInverted={this.props.renderInverted}
logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
setLogicalSpaceViewportSize={this.setLogicalSpaceViewportSize}
searchIsActive={this.props.searchIsActive}
searchQuery={this.props.searchQuery}
searchResults={null}
/>
{this.renderTooltip()}
</div>
@@ -14,7 +14,7 @@ import {
} from '../store/getters'
import {FlamechartID} from '../store/flamechart-view-state'
import {useAppSelector} from '../store'
import {FlamechartWrapper, useDummySearchProps} from './flamechart-wrapper'
import {FlamechartWrapper} from './flamechart-wrapper'
import {h} from 'preact'
import {memo} from 'preact/compat'
@@ -87,14 +87,9 @@ export const InvertedCallerFlamegraphView = memo((ownProps: FlamechartViewContai
canvasContext={canvasContext}
getCSSColorForFrame={getCSSColorForFrame}
{...useFlamechartSetters(FlamechartID.SANDWICH_INVERTED_CALLERS, index)}
{...callerCallee.invertedCallerFlamegraph}
// This overrides the setSelectedNode specified in useFlamechartSettesr
setSelectedNode={noop}
{...callerCallee.invertedCallerFlamegraph}
/*
* TODO(jlfwong): When implementing search for the sandwich views,
* change these flags
* */
{...useDummySearchProps()}
/>
)
})
+23 -52
View File
@@ -1,18 +1,17 @@
import {h, Component, JSX, ComponentChild} from 'preact'
import {StyleSheet, css} from 'aphrodite'
import {Profile, Frame} from '../lib/profile'
import {sortBy, formatPercent} from '../lib/utils'
import {formatPercent} from '../lib/utils'
import {FontSize, Colors, Sizes, commonStyle} from './style'
import {ColorChit} from './color-chit'
import {ListItem, ScrollableListView} from './scrollable-list-view'
import {actions} from '../store/actions'
import {createGetCSSColorForFrame, getFrameToColorBucket} from '../store/getters'
import {ActiveProfileState} from './application'
import {useActionCreator} from '../lib/preact-redux'
import {useAppSelector} from '../store'
import {useAppSelector, ActiveProfileState} from '../store'
import {memo} from 'preact/compat'
import {useCallback, useMemo} from 'preact/hooks'
import {fuzzyMatchStrings} from '../lib/fuzzy-find'
import {useCallback, useMemo, useContext} from 'preact/hooks'
import {SandwichViewContext} from './sandwich-view'
export enum SortField {
SYMBOL_NAME,
@@ -68,13 +67,9 @@ class SortIcon extends Component<SortIconProps, {}> {
}
}
interface ProfileTableRowInfo {
interface ProfileTableRowViewProps {
frame: Frame
matchedRanges: [number, number][] | null
}
interface ProfileTableRowViewProps {
info: ProfileTableRowInfo
index: number
profile: Profile
selectedFrame: Frame | null
@@ -100,15 +95,14 @@ function highlightRanges(
}
const ProfileTableRowView = ({
info,
frame,
matchedRanges,
profile,
index,
selectedFrame,
setSelectedFrame,
getCSSColorForFrame,
}: ProfileTableRowViewProps) => {
const {frame, matchedRanges} = info
const totalWeight = frame.getTotalWeight()
const selfWeight = frame.getSelfWeight()
const totalPerc = (100.0 * totalWeight) / profile.getTotalNonIdleWeight()
@@ -206,48 +200,21 @@ export const ProfileTableView = memo(
[sortMethod, setSortMethod],
)
const rowList = useMemo((): {frame: Frame; matchedRanges: [number, number][] | null}[] => {
const rowList: ProfileTableRowInfo[] = []
profile.forEachFrame(frame => {
let matchedRanges: [number, number][] | null = null
if (searchIsActive) {
const match = fuzzyMatchStrings(frame.name, searchQuery)
if (match == null) return
matchedRanges = match.matchedRanges
}
rowList.push({frame, matchedRanges})
})
switch (sortMethod.field) {
case SortField.SYMBOL_NAME: {
sortBy(rowList, f => f.frame.name.toLowerCase())
break
}
case SortField.SELF: {
sortBy(rowList, f => f.frame.getSelfWeight())
break
}
case SortField.TOTAL: {
sortBy(rowList, f => f.frame.getTotalWeight())
break
}
}
if (sortMethod.direction === SortDirection.DESCENDING) {
rowList.reverse()
}
return rowList
}, [profile, sortMethod, searchQuery, searchIsActive])
const sandwichContext = useContext(SandwichViewContext)
const renderItems = useCallback(
(firstIndex: number, lastIndex: number) => {
if (!sandwichContext) return null
const rows: JSX.Element[] = []
for (let i = firstIndex; i <= lastIndex; i++) {
const frame = sandwichContext.rowList[i]
const match = sandwichContext.getSearchMatchForFrame(frame)
rows.push(
ProfileTableRowView({
info: rowList[i],
frame,
matchedRanges: match == null ? null : match.matchedRanges,
index: i,
profile: profile,
selectedFrame: selectedFrame,
@@ -278,7 +245,7 @@ export const ProfileTableView = memo(
return <table className={css(style.tableView)}>{rows}</table>
},
[
rowList,
sandwichContext,
profile,
selectedFrame,
setSelectedFrame,
@@ -288,9 +255,13 @@ export const ProfileTableView = memo(
],
)
const listItems: ListItem[] = useMemo(() => rowList.map(f => ({size: Sizes.FRAME_HEIGHT})), [
rowList,
])
const listItems: ListItem[] = useMemo(
() =>
sandwichContext == null
? []
: sandwichContext.rowList.map(f => ({size: Sizes.FRAME_HEIGHT})),
[sandwichContext],
)
const onTotalClick = useCallback((ev: MouseEvent) => onSortClick(SortField.TOTAL, ev), [
onSortClick,
@@ -341,7 +312,7 @@ export const ProfileTableView = memo(
className={css(style.scrollView)}
renderItems={renderItems}
initialIndexInView={
selectedFrame == null ? null : rowList.findIndex(f => f.frame === selectedFrame)
selectedFrame == null ? null : sandwichContext?.getIndexForFrame(selectedFrame)
}
/>
</div>
+44
View File
@@ -0,0 +1,44 @@
import {memo} from 'preact/compat'
import {useContext, useMemo} from 'preact/hooks'
import {SearchView} from './search-view'
import {h} from 'preact'
import {SandwichViewContext} from './sandwich-view'
export const SandwichSearchView = memo(() => {
const sandwichViewContext = useContext(SandwichViewContext)
const rowList = sandwichViewContext != null ? sandwichViewContext.rowList : null
const resultIndex =
sandwichViewContext?.selectedFrame != null
? sandwichViewContext.getIndexForFrame(sandwichViewContext.selectedFrame)
: null
const numResults = rowList != null ? rowList.length : null
const {selectPrev, selectNext} = useMemo(() => {
if (rowList == null || numResults == null || numResults === 0 || sandwichViewContext == null) {
return {selectPrev: () => {}, selectNext: () => {}}
}
return {
selectPrev: () => {
let index = resultIndex == null ? numResults - 1 : resultIndex - 1
if (index < 0) index = numResults - 1
sandwichViewContext.setSelectedFrame(rowList[index])
},
selectNext: () => {
let index = resultIndex == null ? 0 : resultIndex + 1
if (index >= numResults) index = 0
sandwichViewContext.setSelectedFrame(rowList[index])
},
}
}, [resultIndex, rowList, numResults, sandwichViewContext])
return (
<SearchView
resultIndex={resultIndex}
numResults={numResults}
selectPrev={selectPrev}
selectNext={selectNext}
/>
)
})
+92 -36
View File
@@ -1,18 +1,20 @@
import {Frame} from '../lib/profile'
import {StyleSheet, css} from 'aphrodite'
import {ProfileTableViewContainer} from './profile-table-view'
import {h, JSX} from 'preact'
import {ProfileTableViewContainer, SortField, SortDirection} from './profile-table-view'
import {h, JSX, createContext} from 'preact'
import {memo} from 'preact/compat'
import {useCallback} from 'preact/hooks'
import {useCallback, useMemo, useContext} from 'preact/hooks'
import {commonStyle, Sizes, Colors, FontSize} from './style'
import {actions} from '../store/actions'
import {StatelessComponent} from '../lib/typed-redux'
import {InvertedCallerFlamegraphView} from './inverted-caller-flamegraph-view'
import {CalleeFlamegraphView} from './callee-flamegraph-view'
import {ActiveProfileState} from './application'
import {useDispatch, useActionCreator} from '../lib/preact-redux'
import {SearchView} from './search-view'
import {useAppSelector} from '../store'
import {useDispatch} from '../lib/preact-redux'
import {SandwichSearchView} from './sandwich-search-view'
import {useAppSelector, ActiveProfileState} from '../store'
import {sortBy} from '../lib/utils'
import {ProfileSearchContext} from './search-view'
import {FuzzyMatch} from '../lib/fuzzy-find'
interface SandwichViewProps {
selectedFrame: Frame | null
@@ -20,10 +22,6 @@ interface SandwichViewProps {
activeProfileState: ActiveProfileState
setSelectedFrame: (selectedFrame: Frame | null) => void
glCanvas: HTMLCanvasElement
searchQuery: string
searchIsActive: boolean
setSearchQuery: (query: string | null) => void
setSearchIsActive: (active: boolean) => void
}
class SandwichView extends StatelessComponent<SandwichViewProps> {
@@ -45,13 +43,7 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
}
render() {
const {
selectedFrame,
searchIsActive,
setSearchIsActive,
searchQuery,
setSearchQuery,
} = this.props
const {selectedFrame} = this.props
let flamegraphViews: JSX.Element | null = null
if (selectedFrame) {
@@ -84,12 +76,7 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
<div className={css(commonStyle.hbox, commonStyle.fillY)}>
<div className={css(style.tableView)}>
<ProfileTableViewContainer activeProfileState={this.props.activeProfileState} />
<SearchView
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchIsActive={searchIsActive}
setSearchIsActive={setSearchIsActive}
/>
<SandwichSearchView />
</div>
{flamegraphViews}
</div>
@@ -143,7 +130,15 @@ interface SandwichViewContainerProps {
glCanvas: HTMLCanvasElement
}
const {setSearchQuery, setSearchIsActive} = actions
interface SandwichViewContextData {
rowList: Frame[]
selectedFrame: Frame | null
setSelectedFrame: (frame: Frame | null) => void
getIndexForFrame: (frame: Frame) => number | null
getSearchMatchForFrame: (frame: Frame) => FuzzyMatch | null
}
export const SandwichViewContext = createContext<SandwichViewContextData | null>(null)
export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps) => {
const {activeProfileState, glCanvas} = ownProps
@@ -163,17 +158,78 @@ export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps)
[dispatch, index],
)
const profile = activeProfileState.profile
const tableSortMethod = useAppSelector(state => state.tableSortMethod, [])
const profileSearchResults = useContext(ProfileSearchContext)
const selectedFrame = callerCallee ? callerCallee.selectedFrame : null
const rowList: Frame[] = useMemo(() => {
const rowList: Frame[] = []
profile.forEachFrame(frame => {
if (profileSearchResults && !profileSearchResults.getMatchForFrame(frame)) {
return
}
rowList.push(frame)
})
switch (tableSortMethod.field) {
case SortField.SYMBOL_NAME: {
sortBy(rowList, f => f.name.toLowerCase())
break
}
case SortField.SELF: {
sortBy(rowList, f => f.getSelfWeight())
break
}
case SortField.TOTAL: {
sortBy(rowList, f => f.getTotalWeight())
break
}
}
if (tableSortMethod.direction === SortDirection.DESCENDING) {
rowList.reverse()
}
return rowList
}, [profile, profileSearchResults, tableSortMethod])
const getIndexForFrame: (frame: Frame) => number | null = useMemo(() => {
const indexByFrame = new Map<Frame, number>()
for (let i = 0; i < rowList.length; i++) {
indexByFrame.set(rowList[i], i)
}
return (frame: Frame) => {
const index = indexByFrame.get(frame)
return index == null ? null : index
}
}, [rowList])
const getSearchMatchForFrame: (frame: Frame) => FuzzyMatch | null = useMemo(() => {
return (frame: Frame) => {
if (profileSearchResults == null) return null
return profileSearchResults.getMatchForFrame(frame)
}
}, [profileSearchResults])
const contextData: SandwichViewContextData = {
rowList,
selectedFrame,
setSelectedFrame,
getIndexForFrame,
getSearchMatchForFrame,
}
return (
<SandwichView
activeProfileState={activeProfileState}
glCanvas={glCanvas}
setSelectedFrame={setSelectedFrame}
selectedFrame={callerCallee ? callerCallee.selectedFrame : null}
profileIndex={index}
searchQuery={useAppSelector(state => state.searchQuery, [])}
setSearchQuery={useActionCreator(setSearchQuery, [])}
searchIsActive={useAppSelector(state => state.searchIsActive, [])}
setSearchIsActive={useActionCreator(setSearchIsActive, [])}
/>
<SandwichViewContext.Provider value={contextData}>
<SandwichView
activeProfileState={activeProfileState}
glCanvas={glCanvas}
setSelectedFrame={setSelectedFrame}
selectedFrame={selectedFrame}
profileIndex={index}
/>
</SandwichViewContext.Provider>
)
})
+4 -1
View File
@@ -17,7 +17,10 @@ interface RangeResult {
interface ScrollableListViewProps {
items: ListItem[]
axis: 'x' | 'y'
renderItems: (firstVisibleIndex: number, lastVisibleIndex: number) => JSX.Element | JSX.Element[]
renderItems: (
firstVisibleIndex: number,
lastVisibleIndex: number,
) => JSX.Element | JSX.Element[] | null
className?: string
initialIndexInView?: number | null
}
+108 -27
View File
@@ -1,23 +1,54 @@
import {StyleSheet, css} from 'aphrodite'
import {h} from 'preact'
import {useCallback, useRef, useEffect} from 'preact/hooks'
import {h, createContext, ComponentChildren, Fragment} from 'preact'
import {useCallback, useRef, useEffect, useMemo} from 'preact/hooks'
import {memo} from 'preact/compat'
import {Sizes, Colors, FontSize} from './style'
import {ProfileSearchResults} from '../lib/profile-search'
import {Profile} from '../lib/profile'
import {useActiveProfileState, useAppSelector} from '../store'
import {useActionCreator} from '../lib/preact-redux'
import {actions} from '../store/actions'
function stopPropagation(ev: Event) {
ev.stopPropagation()
}
export interface SearchViewProps {
searchQuery: string
searchIsActive: boolean
export const ProfileSearchContext = createContext<ProfileSearchResults | null>(null)
setSearchQuery: (query: string) => void
setSearchIsActive: (active: boolean) => void
export const ProfileSearchContextProvider = ({children}: {children: ComponentChildren}) => {
const activeProfileState = useActiveProfileState()
const profile: Profile | null = activeProfileState ? activeProfileState.profile : null
const searchIsActive = useAppSelector(state => state.searchIsActive, [])
const searchQuery = useAppSelector(state => state.searchQuery, [])
const searchResults = useMemo(() => {
if (!profile || !searchIsActive || searchQuery.length === 0) {
return null
}
return new ProfileSearchResults(profile, searchQuery)
}, [searchIsActive, searchQuery, profile])
return (
<ProfileSearchContext.Provider value={searchResults}>{children}</ProfileSearchContext.Provider>
)
}
const {setSearchQuery: setSearchQueryAction, setSearchIsActive: setSearchIsActiveAction} = actions
interface SearchViewProps {
resultIndex: number | null
numResults: number | null
selectNext: () => void
selectPrev: () => void
}
export const SearchView = memo(
({searchQuery, setSearchQuery, searchIsActive, setSearchIsActive}: SearchViewProps) => {
({numResults, resultIndex, selectNext, selectPrev}: SearchViewProps) => {
const searchQuery = useAppSelector(state => state.searchQuery, [])
const searchIsActive = useAppSelector(state => state.searchIsActive, [])
const setSearchQuery = useActionCreator(setSearchQueryAction, [])
const setSearchIsActive = useActionCreator(setSearchIsActiveAction, [])
const onInput = useCallback(
(ev: Event) => {
const value = (ev.target as HTMLInputElement).value
@@ -28,6 +59,19 @@ export const SearchView = memo(
const inputRef = useRef<HTMLInputElement | null>(null)
const close = useCallback(() => setSearchIsActive(false), [setSearchIsActive])
const selectPrevOrNextResult = useCallback(
(ev: KeyboardEvent) => {
if (ev.shiftKey) {
selectPrev()
} else {
selectNext()
}
},
[selectPrev, selectNext],
)
const onKeyDown = useCallback(
(ev: KeyboardEvent) => {
ev.stopPropagation()
@@ -37,6 +81,10 @@ export const SearchView = memo(
setSearchIsActive(false)
}
if (ev.key === 'Enter') {
selectPrevOrNextResult(ev)
}
if (ev.key == 'f' && (ev.metaKey || ev.ctrlKey)) {
if (inputRef.current) {
// If the input is already focused, select all
@@ -49,7 +97,7 @@ export const SearchView = memo(
ev.preventDefault()
}
},
[setSearchIsActive],
[setSearchIsActive, selectPrevOrNextResult],
)
useEffect(() => {
@@ -81,24 +129,37 @@ export const SearchView = memo(
}
}, [setSearchIsActive])
const close = useCallback(() => setSearchIsActive(false), [setSearchIsActive])
if (!searchIsActive) return null
return (
<div className={css(style.searchView)}>
<span className={css(style.icon)}>🔍</span>
<input
className={css(style.input)}
value={searchQuery}
onInput={onInput}
onKeyDown={onKeyDown}
onKeyUp={stopPropagation}
onKeyPress={stopPropagation}
ref={inputRef}
/>
<span className={css(style.inputContainer)}>
<input
className={css(style.input)}
value={searchQuery}
onInput={onInput}
onKeyDown={onKeyDown}
onKeyUp={stopPropagation}
onKeyPress={stopPropagation}
ref={inputRef}
/>
</span>
{numResults != null && (
<Fragment>
<span className={css(style.resultCount)}>
{resultIndex == null ? '?' : resultIndex + 1}/{numResults}
</span>
<button className={css(style.icon, style.button)} onClick={selectPrev}>
⬅️
</button>
<button className={css(style.icon, style.button)} onClick={selectNext}>
➡️
</button>
</Fragment>
)}
<svg
className={css(style.icon)}
onClick={close}
width="16"
height="16"
@@ -122,7 +183,7 @@ const style = StyleSheet.create({
top: 0,
right: 10,
height: Sizes.TOOLBAR_HEIGHT,
width: 150,
width: 16 * 13,
borderWidth: 2,
borderColor: Colors.BLACK,
borderStyle: 'solid',
@@ -131,12 +192,19 @@ const style = StyleSheet.create({
background: Colors.DARK_GRAY,
color: Colors.WHITE,
display: 'flex',
alignItems: 'center',
},
inputContainer: {
flexShrink: 1,
flexGrow: 1,
display: 'flex',
},
input: {
width: '100%',
border: 'none',
background: 'none',
fontSize: FontSize.LABEL,
flex: 1,
lineHeight: `${Sizes.TOOLBAR_HEIGHT}px`,
color: Colors.WHITE,
':focus': {
border: 'none',
@@ -147,10 +215,23 @@ const style = StyleSheet.create({
background: Colors.DARK_BLUE,
},
},
icon: {
display: 'inline-block',
resultCount: {
verticalAlign: 'middle',
paddingTop: '0px',
margin: '0 2px 0 4px',
},
icon: {
flexShrink: 0,
verticalAlign: 'middle',
height: '100%',
margin: '0px 2px 0px 2px',
fontSize: FontSize.LABEL,
},
button: {
display: 'inline',
background: 'none',
border: 'none',
padding: 0,
':focus': {
outline: 'none',
},
},
})
+1
View File
@@ -23,6 +23,7 @@ export enum Colors {
GREEN = '#6FCF97',
TRANSPARENT_GREEN = 'rgba(111, 207, 151, 0.2)',
YELLOW = '#FEDC62',
ORANGE = '#FFAC02',
}
export enum Sizes {