Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3b35d7b0f | ||
|
|
dfaefe54fd | ||
|
|
7514f4c0c9 | ||
|
|
ff447c2719 | ||
|
|
668bb032ba |
@@ -1,5 +1,21 @@
|
||||
## Unreleased
|
||||
|
||||
## [1.8.0] - 2020-07-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added search highlighting in time order & left heavy views [[#297](https://github.com/jlfwong/speedscope/pull/297)]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix performance issues for the caller/callee flamegraphs in the sandwich view [[#296](https://github.com/jlfwong/speedscope/pull/296)]
|
||||
|
||||
## [1.7.0] - 2020-07-13
|
||||
|
||||
### Added
|
||||
|
||||
- Introduced filtering via Ctrl+F/Cmd+F into the sandwich view [[#293](https://github.com/jlfwong/speedscope/pull/293)]
|
||||
|
||||
## [1.6.0] - 2020-05-30
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "speedscope",
|
||||
"version": "1.6.0",
|
||||
"version": "1.8.0",
|
||||
"description": "",
|
||||
"repository": "jlfwong/speedscope",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import {buildTrimmedText, ELLIPSIS, remapRangesToTrimmedText} from './text-utils'
|
||||
import {fuzzyMatchStrings} from './fuzzy-find'
|
||||
|
||||
function assertTrimmed(text: string, length: number, expectedTrimmed: string) {
|
||||
expect(buildTrimmedText(text, length).trimmedString).toEqual(
|
||||
expectedTrimmed.replace('...', ELLIPSIS),
|
||||
)
|
||||
}
|
||||
|
||||
test('buildTrimmedText', () => {
|
||||
assertTrimmed('hello world', 1, '...')
|
||||
assertTrimmed('hello world', 2, 'h...')
|
||||
assertTrimmed('hello world', 3, 'h...d')
|
||||
assertTrimmed('hello world', 4, 'he...d')
|
||||
assertTrimmed('hello world', 10, 'hello...orld')
|
||||
assertTrimmed('hello world', 11, 'hello world')
|
||||
assertTrimmed('hello world', 100, 'hello world')
|
||||
})
|
||||
|
||||
function highlightText(text: string, highlightedRanges: [number, number][]): string {
|
||||
let last = 0
|
||||
let highlighted = ''
|
||||
for (let range of highlightedRanges) {
|
||||
highlighted += `${text.slice(last, range[0])}[${text.slice(range[0], range[1])}]`
|
||||
last = range[1]
|
||||
}
|
||||
highlighted += text.slice(last)
|
||||
return highlighted
|
||||
}
|
||||
|
||||
function assertTrimmedHighlight({
|
||||
text,
|
||||
pattern,
|
||||
expectedHighlighted,
|
||||
length,
|
||||
expectedHighlightedTrimmed,
|
||||
}: {
|
||||
text: string
|
||||
pattern: string
|
||||
expectedHighlighted: string
|
||||
length: number
|
||||
expectedHighlightedTrimmed: string
|
||||
}) {
|
||||
const match = fuzzyMatchStrings(text, pattern)
|
||||
const trimmed = buildTrimmedText(text, length)
|
||||
|
||||
if (!match) {
|
||||
fail()
|
||||
return
|
||||
}
|
||||
|
||||
const matchedRangesForTrimmedText = remapRangesToTrimmedText(trimmed, match.matchedRanges)
|
||||
const highlighted = highlightText(text, match.matchedRanges)
|
||||
const highlightedTrimmed = highlightText(trimmed.trimmedString, matchedRangesForTrimmedText)
|
||||
|
||||
expect(highlighted).toEqual(expectedHighlighted)
|
||||
expect(highlightedTrimmed).toEqual(expectedHighlightedTrimmed.replace('...', ELLIPSIS))
|
||||
}
|
||||
|
||||
test('remapRangesToTrimmedText', () => {
|
||||
assertTrimmedHighlight({
|
||||
text: 'hello world',
|
||||
pattern: 'he',
|
||||
length: 4,
|
||||
expectedHighlighted: '[he]llo world',
|
||||
expectedHighlightedTrimmed: `[he]...d`,
|
||||
})
|
||||
|
||||
assertTrimmedHighlight({
|
||||
text: 'hello world',
|
||||
pattern: 'o w',
|
||||
length: 4,
|
||||
expectedHighlighted: 'hell[o w]orld',
|
||||
expectedHighlightedTrimmed: `he[...]d`,
|
||||
})
|
||||
|
||||
assertTrimmedHighlight({
|
||||
text: 'hello world',
|
||||
pattern: 'ow',
|
||||
length: 4,
|
||||
expectedHighlighted: 'hell[o] [w]orld',
|
||||
expectedHighlightedTrimmed: `he[...]d`,
|
||||
})
|
||||
|
||||
assertTrimmedHighlight({
|
||||
text: 'hello world',
|
||||
pattern: 'hello',
|
||||
length: 4,
|
||||
expectedHighlighted: '[hello] world',
|
||||
expectedHighlightedTrimmed: `[he...]d`,
|
||||
})
|
||||
|
||||
assertTrimmedHighlight({
|
||||
text: 'hello world',
|
||||
pattern: 'hello world',
|
||||
length: 4,
|
||||
expectedHighlighted: '[hello world]',
|
||||
expectedHighlightedTrimmed: `[he...d]`,
|
||||
})
|
||||
|
||||
assertTrimmedHighlight({
|
||||
text: 'hello world',
|
||||
pattern: 'helloworld',
|
||||
length: 4,
|
||||
expectedHighlighted: '[hello] [world]',
|
||||
expectedHighlightedTrimmed: `[he...][d]`,
|
||||
})
|
||||
|
||||
assertTrimmedHighlight({
|
||||
text: 'hello world',
|
||||
pattern: 'world',
|
||||
length: 4,
|
||||
expectedHighlighted: 'hello [world]',
|
||||
expectedHighlightedTrimmed: `he[...d]`,
|
||||
})
|
||||
})
|
||||
+174
-8
@@ -18,22 +18,188 @@ export function cachedMeasureTextWidth(ctx: CanvasRenderingContext2D, text: stri
|
||||
return measureTextCache.get(text)!
|
||||
}
|
||||
|
||||
function buildTrimmedText(text: string, length: number) {
|
||||
const prefixLength = Math.floor(length / 2)
|
||||
const prefix = text.substr(0, prefixLength)
|
||||
const suffix = text.substr(text.length - prefixLength, prefixLength)
|
||||
return prefix + ELLIPSIS + suffix
|
||||
interface TrimmedTextResult {
|
||||
trimmedString: string
|
||||
trimmedLength: number
|
||||
prefixLength: number
|
||||
suffixLength: number
|
||||
originalLength: number
|
||||
originalString: string
|
||||
}
|
||||
|
||||
export function trimTextMid(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) {
|
||||
if (cachedMeasureTextWidth(ctx, text) <= maxWidth) return text
|
||||
// Trim text, placing an ellipsis in the middle, with a slight bias towards
|
||||
// keeping text from the beginning rather than the end
|
||||
export function buildTrimmedText(text: string, length: number): TrimmedTextResult {
|
||||
if (text.length <= length) {
|
||||
return {
|
||||
trimmedString: text,
|
||||
trimmedLength: text.length,
|
||||
prefixLength: text.length,
|
||||
suffixLength: 0,
|
||||
originalString: text,
|
||||
originalLength: text.length,
|
||||
}
|
||||
}
|
||||
|
||||
let prefixLength = Math.floor(length / 2)
|
||||
const suffixLength = length - prefixLength - 1
|
||||
const prefix = text.substr(0, prefixLength)
|
||||
const suffix = text.substr(text.length - suffixLength, suffixLength)
|
||||
const trimmedString = prefix + ELLIPSIS + suffix
|
||||
return {
|
||||
trimmedString,
|
||||
trimmedLength: trimmedString.length,
|
||||
prefixLength: prefix.length,
|
||||
suffixLength: suffix.length,
|
||||
originalString: text,
|
||||
originalLength: text.length,
|
||||
}
|
||||
}
|
||||
|
||||
// Trim text to fit within the given number of pixels on the canvas
|
||||
export function trimTextMid(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
): TrimmedTextResult {
|
||||
if (cachedMeasureTextWidth(ctx, text) <= maxWidth) {
|
||||
return buildTrimmedText(text, text.length)
|
||||
}
|
||||
const [lo] = binarySearch(
|
||||
0,
|
||||
text.length,
|
||||
n => {
|
||||
return cachedMeasureTextWidth(ctx, buildTrimmedText(text, n))
|
||||
return cachedMeasureTextWidth(ctx, buildTrimmedText(text, n).trimmedString)
|
||||
},
|
||||
maxWidth,
|
||||
)
|
||||
return buildTrimmedText(text, lo)
|
||||
}
|
||||
|
||||
enum IndexTypeInTrimmed {
|
||||
IN_PREFIX,
|
||||
IN_SUFFIX,
|
||||
ELIDED,
|
||||
}
|
||||
|
||||
function getIndexTypeInTrimmed(result: TrimmedTextResult, index: number): IndexTypeInTrimmed {
|
||||
if (index < result.prefixLength) {
|
||||
return IndexTypeInTrimmed.IN_PREFIX
|
||||
} else if (index < result.originalLength - result.suffixLength) {
|
||||
return IndexTypeInTrimmed.ELIDED
|
||||
} else {
|
||||
return IndexTypeInTrimmed.IN_SUFFIX
|
||||
}
|
||||
}
|
||||
|
||||
export function remapRangesToTrimmedText(
|
||||
trimmedText: TrimmedTextResult,
|
||||
ranges: [number, number][],
|
||||
): [number, number][] {
|
||||
// We intentionally don't just re-run fuzzy matching on the trimmed
|
||||
// text, beacuse if the search query is "helloWorld", the frame name
|
||||
// is "application::helloWorld", and that gets trimmed down to
|
||||
// "appl...oWorld", we still want "oWorld" to be highlighted, even
|
||||
// though the string "appl...oWorld" is not matched by the query
|
||||
// "helloWorld".
|
||||
//
|
||||
// There's a weird case to consider here: what if the trimmedText is
|
||||
// also matched by the query, but results in a different match than
|
||||
// the original query? Consider, e.g. the search string of "ab". The
|
||||
// string "hello ab shabby" will be matched at the first "ab", but
|
||||
// may be trimmed to "hello...shabby". In this case, should we
|
||||
// highlight the "ab" hidden by the ellipsis, or the "ab" in
|
||||
// "shabby"? The code below highlights the ellipsis so that the
|
||||
// matched characters don't change as you zoom in and out.
|
||||
|
||||
const rangesToHighlightInTrimmedText: [number, number][] = []
|
||||
const lengthLoss = trimmedText.originalLength - trimmedText.trimmedLength
|
||||
let highlightedEllipsis = false
|
||||
|
||||
for (let [origStart, origEnd] of ranges) {
|
||||
let startPosType = getIndexTypeInTrimmed(trimmedText, origStart)
|
||||
let endPosType = getIndexTypeInTrimmed(trimmedText, origEnd - 1)
|
||||
|
||||
switch (startPosType) {
|
||||
case IndexTypeInTrimmed.IN_PREFIX: {
|
||||
switch (endPosType) {
|
||||
case IndexTypeInTrimmed.IN_PREFIX: {
|
||||
// The entire range fits in the prefix. Add it unmodified.
|
||||
rangesToHighlightInTrimmedText.push([origStart, origEnd])
|
||||
break
|
||||
}
|
||||
case IndexTypeInTrimmed.ELIDED: {
|
||||
// The range starts in the prefix, but ends in the elided
|
||||
// section. Add just the prefix + one char for the ellipsis.
|
||||
rangesToHighlightInTrimmedText.push([
|
||||
origStart,
|
||||
origStart + trimmedText.prefixLength + 1,
|
||||
])
|
||||
highlightedEllipsis = true
|
||||
break
|
||||
}
|
||||
case IndexTypeInTrimmed.IN_SUFFIX: {
|
||||
// The range crosses from the prefix to the suffix.
|
||||
// Highlight everything including the ellipsis.
|
||||
rangesToHighlightInTrimmedText.push([origStart, origEnd - lengthLoss])
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case IndexTypeInTrimmed.ELIDED: {
|
||||
switch (endPosType) {
|
||||
case IndexTypeInTrimmed.IN_PREFIX: {
|
||||
// This should be impossible
|
||||
throw new Error('Unexpected highlight range starts in elided and ends in prefix')
|
||||
}
|
||||
case IndexTypeInTrimmed.ELIDED: {
|
||||
// The match starts & ends within the elided section.
|
||||
if (!highlightedEllipsis) {
|
||||
rangesToHighlightInTrimmedText.push([
|
||||
trimmedText.prefixLength,
|
||||
trimmedText.prefixLength + 1,
|
||||
])
|
||||
highlightedEllipsis = true
|
||||
}
|
||||
break
|
||||
}
|
||||
case IndexTypeInTrimmed.IN_SUFFIX: {
|
||||
// The match starts in elided, but ends in suffix.
|
||||
if (highlightedEllipsis) {
|
||||
rangesToHighlightInTrimmedText.push([
|
||||
trimmedText.trimmedLength - trimmedText.suffixLength,
|
||||
origEnd - lengthLoss,
|
||||
])
|
||||
} else {
|
||||
rangesToHighlightInTrimmedText.push([trimmedText.prefixLength, origEnd - lengthLoss])
|
||||
highlightedEllipsis = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case IndexTypeInTrimmed.IN_SUFFIX: {
|
||||
switch (endPosType) {
|
||||
case IndexTypeInTrimmed.IN_PREFIX: {
|
||||
// This should be impossible
|
||||
throw new Error('Unexpected highlight range starts in suffix and ends in prefix')
|
||||
}
|
||||
case IndexTypeInTrimmed.ELIDED: {
|
||||
// This should be impossible
|
||||
throw new Error('Unexpected highlight range starts in suffix and ends in elided')
|
||||
break
|
||||
}
|
||||
case IndexTypeInTrimmed.IN_SUFFIX: {
|
||||
// Match starts & ends in suffix
|
||||
rangesToHighlightInTrimmedText.push([origStart - lengthLoss, origEnd - lengthLoss])
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return rangesToHighlightInTrimmedText
|
||||
}
|
||||
|
||||
+2
-19
@@ -8,36 +8,19 @@ import {HashParams} from '../lib/hash-params'
|
||||
import {actionCreatorWithIndex} from './profiles-state'
|
||||
|
||||
export namespace actions {
|
||||
// Set the top-level profile group from which other data will be derived
|
||||
export const setProfileGroup = actionCreator<ProfileGroup>('setProfileGroup')
|
||||
|
||||
// Set the index into the profile group to view
|
||||
export const setProfileIndexToView = actionCreator<number>('setProfileIndexToView')
|
||||
|
||||
export const setGLCanvas = actionCreator<HTMLCanvasElement | null>('setGLCanvas')
|
||||
|
||||
// Set which top-level view should be displayed
|
||||
export const setViewMode = actionCreator<ViewMode>('setViewMode')
|
||||
|
||||
// Set whether or not recursion should be flattened when viewing flamegraphs
|
||||
export const setFlattenRecursion = actionCreator<boolean>('setFlattenRecursion')
|
||||
|
||||
// Set whether a file drag is currently active. Used to indicate that the
|
||||
// application is a valid drop target.
|
||||
export const setSearchQuery = actionCreator<string>('setSearchQuery')
|
||||
export const setSearchIsActive = actionCreator<boolean>('setSearchIsActive')
|
||||
export const setDragActive = actionCreator<boolean>('setDragActive')
|
||||
|
||||
// Set whether the application is currently in a loading state. Used to
|
||||
// display a loading progress bar.
|
||||
export const setLoading = actionCreator<boolean>('setLoading')
|
||||
|
||||
// Set whether the application is in an errored state.
|
||||
export const setError = actionCreator<boolean>('setError')
|
||||
|
||||
// Set whether parameters defined by the URL encoded k=v pairs after the # in the URL
|
||||
export const setHashParams = actionCreator<HashParams>('setHashParams')
|
||||
|
||||
export namespace sandwichView {
|
||||
// Set the table sorting method used for the sandwich view.
|
||||
export const setTableSortMethod = actionCreator<SortMethod>('sandwichView.setTableSortMethod')
|
||||
|
||||
export const setSelectedFrame = actionCreatorWithIndex<Frame | null>(
|
||||
|
||||
@@ -40,10 +40,6 @@ export const getRowAtlas = memoizeByReference((canvasContext: CanvasContext) =>
|
||||
)
|
||||
})
|
||||
|
||||
export const getProfileWithRecursionFlattened = memoizeByReference((profile: Profile) =>
|
||||
profile.getProfileWithRecursionFlattened(),
|
||||
)
|
||||
|
||||
export const getProfileToView = memoizeByShallowEquality(
|
||||
({profile, flattenRecursion}: {profile: Profile; flattenRecursion: boolean}): Profile => {
|
||||
return flattenRecursion ? profile.getProfileWithRecursionFlattened() : profile
|
||||
|
||||
+29
-3
@@ -19,20 +19,43 @@ export const enum ViewMode {
|
||||
}
|
||||
|
||||
export interface ApplicationState {
|
||||
// The top-level profile group from which most other data will be derived
|
||||
profileGroup: ProfileGroupState
|
||||
|
||||
// Parameters defined by the URL encoded k=v pairs after the # in the URL
|
||||
hashParams: HashParams
|
||||
|
||||
glCanvas: HTMLCanvasElement | null
|
||||
|
||||
// Which top-level view should be displayed
|
||||
viewMode: ViewMode
|
||||
|
||||
// True if recursion should be flattened when viewing flamegraphs
|
||||
flattenRecursion: boolean
|
||||
|
||||
viewMode: ViewMode
|
||||
// The query used in top-level views
|
||||
//
|
||||
// An empty string indicates that the search is open by no filter is applied.
|
||||
// searchIsActive is stored separately, because we may choose to persist the
|
||||
// query even when the search input is closed.
|
||||
searchQuery: string
|
||||
searchIsActive: boolean
|
||||
|
||||
// True when a file drag is currently active. Used to indicate that the
|
||||
// application is a valid drop target.
|
||||
dragActive: boolean
|
||||
|
||||
// True when the application is currently in a loading state. Used to
|
||||
// display a loading progress bar.
|
||||
loading: boolean
|
||||
|
||||
// True when the application is an error state, e.g. because the profile
|
||||
// imported was invalid.
|
||||
error: boolean
|
||||
|
||||
// The table sorting method using for the sandwich view, specifying the column
|
||||
// to sort by, and the direction to sort that clumn.
|
||||
tableSortMethod: SortMethod
|
||||
|
||||
profileGroup: ProfileGroupState
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol
|
||||
@@ -56,6 +79,9 @@ export function createAppStore(initialState?: ApplicationState): redux.Store<App
|
||||
|
||||
viewMode: setter<ViewMode>(actions.setViewMode, ViewMode.CHRONO_FLAME_CHART),
|
||||
|
||||
searchQuery: setter<string>(actions.setSearchQuery, ''),
|
||||
searchIsActive: setter<boolean>(actions.setSearchIsActive, false),
|
||||
|
||||
glCanvas: setter<HTMLCanvasElement | null>(actions.setGLCanvas, null),
|
||||
|
||||
dragActive: setter<boolean>(actions.setDragActive, false),
|
||||
|
||||
@@ -12,7 +12,10 @@ import {objectsHaveShallowEquality} from '../lib/utils'
|
||||
|
||||
export type ProfileGroupState = {
|
||||
name: string
|
||||
|
||||
// The index within the list of profiles currently being viewed
|
||||
indexToView: number
|
||||
|
||||
profiles: ProfileState[]
|
||||
} | null
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getFrameToColorBucket,
|
||||
} from '../store/getters'
|
||||
import {FlamechartID} from '../store/flamechart-view-state'
|
||||
import {FlamechartWrapper} from './flamechart-wrapper'
|
||||
import {FlamechartWrapper, useDummySearchProps} from './flamechart-wrapper'
|
||||
import {useAppSelector} from '../store'
|
||||
import {h} from 'preact'
|
||||
import {memo} from 'preact/compat'
|
||||
@@ -81,6 +81,11 @@ export const CalleeFlamegraphView = memo((ownProps: FlamechartViewContainerProps
|
||||
// This overrides the setSelectedNode specified in useFlamechartSettesr
|
||||
setSelectedNode={noop}
|
||||
{...callerCallee.calleeFlamegraph}
|
||||
/*
|
||||
* TODO(jlfwong): When implementing search for the sandwich views,
|
||||
* change these flags
|
||||
* */
|
||||
{...useDummySearchProps()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import {Rect, AffineTransform, Vec2, clamp} from '../lib/math'
|
||||
import {CallTreeNode} from '../lib/profile'
|
||||
import {CallTreeNode, Frame} from '../lib/profile'
|
||||
import {Flamechart, FlamechartFrame} from '../lib/flamechart'
|
||||
import {CanvasContext} from '../gl/canvas-context'
|
||||
import {FlamechartRenderer} from '../gl/flamechart-renderer'
|
||||
import {Sizes, FontSize, Colors, FontFamily, commonStyle} from './style'
|
||||
import {cachedMeasureTextWidth, ELLIPSIS, trimTextMid} from '../lib/text-utils'
|
||||
import {
|
||||
cachedMeasureTextWidth,
|
||||
ELLIPSIS,
|
||||
trimTextMid,
|
||||
remapRangesToTrimmedText,
|
||||
} from '../lib/text-utils'
|
||||
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'
|
||||
|
||||
interface FlamechartFrameLabel {
|
||||
configSpaceBounds: Rect
|
||||
@@ -47,7 +54,10 @@ export interface FlamechartPanZoomViewProps {
|
||||
setConfigSpaceViewportRect: (rect: Rect) => void
|
||||
|
||||
logicalSpaceViewportSize: Vec2
|
||||
setLogicalSpaceViewportBounds: (size: Vec2) => void
|
||||
setLogicalSpaceViewportSize: (size: Vec2) => void
|
||||
|
||||
searchIsActive: boolean
|
||||
searchQuery: string
|
||||
}
|
||||
|
||||
export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps, {}> {
|
||||
@@ -183,7 +193,6 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
|
||||
ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${FontFamily.MONOSPACE}`
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
ctx.fillStyle = Colors.DARK_GRAY
|
||||
|
||||
const minWidthToRender = cachedMeasureTextWidth(ctx, 'M' + ELLIPSIS + 'M')
|
||||
const minConfigSpaceWidthToRender = (
|
||||
@@ -192,6 +201,15 @@ 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 renderFrameLabelAndChildren = (frame: FlamechartFrame, depth = 0) => {
|
||||
const width = frame.end - frame.start
|
||||
const y = this.props.renderInverted ? this.configSpaceSize().y - 1 - depth : depth
|
||||
@@ -226,16 +244,52 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
}
|
||||
|
||||
if (physicalLabelBounds.width() > minWidthToRender) {
|
||||
const match = frameMatchesSearchQuery(frame.node.frame)
|
||||
|
||||
const trimmedText = trimTextMid(
|
||||
ctx,
|
||||
frame.node.frame.name,
|
||||
physicalLabelBounds.width() - 2 * LABEL_PADDING_PX,
|
||||
)
|
||||
|
||||
if (match) {
|
||||
const rangesToHighlightInTrimmedText = remapRangesToTrimmedText(
|
||||
trimmedText,
|
||||
match.matchedRanges,
|
||||
)
|
||||
|
||||
// Once we have the character ranges to highlight, we need to
|
||||
// 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 += highlightWidth
|
||||
lastEndIndex = endIndex
|
||||
}
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// Note that this is specifying the position of the starting text
|
||||
// baseline.
|
||||
ctx.fillStyle = Colors.DARK_GRAY
|
||||
ctx.fillText(
|
||||
trimmedText,
|
||||
trimmedText.trimmedString,
|
||||
physicalLabelBounds.left() + LABEL_PADDING_PX,
|
||||
Math.round(
|
||||
physicalLabelBounds.bottom() -
|
||||
@@ -259,8 +313,9 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
const minConfigSpaceWidthToRenderOutline = (
|
||||
configToPhysical.inverseTransformVector(new Vec2(1, 0)) || new Vec2(0, 0)
|
||||
).x
|
||||
const renderIndirectlySelectedFrameOutlines = (frame: FlamechartFrame, depth = 0) => {
|
||||
if (!this.props.selectedNode) return
|
||||
|
||||
const renderSpecialFrameOutlines = (frame: FlamechartFrame, depth = 0) => {
|
||||
if (!this.props.selectedNode && !this.props.searchIsActive) 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))
|
||||
@@ -271,25 +326,30 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
if (configSpaceBounds.top() > this.props.configSpaceViewportRect.bottom()) return
|
||||
|
||||
if (configSpaceBounds.hasIntersectionWith(this.props.configSpaceViewportRect)) {
|
||||
const physicalRectBounds = configToPhysical.transformRect(configSpaceBounds)
|
||||
let outlineColor: string | null = null
|
||||
|
||||
if (frame.node.frame === this.props.selectedNode.frame) {
|
||||
if (this.props.selectedNode != null && frame.node.frame === this.props.selectedNode.frame) {
|
||||
if (frame.node === this.props.selectedNode) {
|
||||
if (ctx.strokeStyle !== Colors.DARK_BLUE) {
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = Colors.DARK_BLUE
|
||||
}
|
||||
} else {
|
||||
if (ctx.strokeStyle !== Colors.PALE_DARK_BLUE) {
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = Colors.PALE_DARK_BLUE
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Identify the flamechart frames with a function that matches the
|
||||
// selected flamechart frame.
|
||||
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),
|
||||
@@ -300,13 +360,13 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
}
|
||||
|
||||
for (let child of frame.children) {
|
||||
renderIndirectlySelectedFrameOutlines(child, depth + 1)
|
||||
renderSpecialFrameOutlines(child, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.beginPath()
|
||||
for (let frame of this.props.flamechart.getLayers()[0] || []) {
|
||||
renderIndirectlySelectedFrameOutlines(frame)
|
||||
renderSpecialFrameOutlines(frame)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
@@ -396,7 +456,11 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
|
||||
),
|
||||
)
|
||||
}
|
||||
this.props.setLogicalSpaceViewportBounds(new Vec2(width, height))
|
||||
|
||||
const newSize = new Vec2(width, height)
|
||||
if (!newSize.equals(logicalSpaceViewportSize)) {
|
||||
this.props.setLogicalSpaceViewportSize(newSize)
|
||||
}
|
||||
}
|
||||
|
||||
onWindowResize = () => {
|
||||
@@ -686,6 +750,11 @@ 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
|
||||
) {
|
||||
this.renderCanvas()
|
||||
} else if (this.props.selectedNode !== nextProps.selectedNode) {
|
||||
this.renderCanvas()
|
||||
} else if (this.props.configSpaceViewportRect !== nextProps.configSpaceViewportRect) {
|
||||
|
||||
@@ -18,6 +18,8 @@ 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'
|
||||
|
||||
interface FlamechartSetters {
|
||||
setLogicalSpaceViewportSize: (logicalSpaceViewportSize: Vec2) => void
|
||||
@@ -64,9 +66,24 @@ 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,
|
||||
@@ -134,6 +151,7 @@ export const ChronoFlamechartView = memo((props: FlamechartViewContainerProps) =
|
||||
canvasContext={canvasContext}
|
||||
getCSSColorForFrame={getCSSColorForFrame}
|
||||
{...useFlamechartSetters(FlamechartID.CHRONO, index)}
|
||||
{...useSearchViewProps()}
|
||||
{...chronoViewState}
|
||||
/>
|
||||
)
|
||||
@@ -185,6 +203,7 @@ export const LeftHeavyFlamechartView = memo((ownProps: FlamechartViewContainerPr
|
||||
canvasContext={canvasContext}
|
||||
getCSSColorForFrame={getCSSColorForFrame}
|
||||
{...useFlamechartSetters(FlamechartID.LEFT_HEAVY, index)}
|
||||
{...useSearchViewProps()}
|
||||
{...leftHeavyViewState}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ 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'
|
||||
|
||||
export class FlamechartView extends StatelessComponent<FlamechartViewProps> {
|
||||
private configSpaceSize() {
|
||||
@@ -113,7 +114,15 @@ export class FlamechartView extends StatelessComponent<FlamechartViewProps> {
|
||||
configSpaceViewportRect={this.props.configSpaceViewportRect}
|
||||
setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
|
||||
logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
|
||||
setLogicalSpaceViewportBounds={this.setLogicalSpaceViewportSize}
|
||||
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}
|
||||
/>
|
||||
{this.renderTooltip()}
|
||||
{this.props.selectedNode && (
|
||||
|
||||
@@ -8,6 +8,17 @@ 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) {
|
||||
@@ -83,7 +94,9 @@ export class FlamechartWrapper extends StatelessComponent<FlamechartViewProps> {
|
||||
canvasContext={this.props.canvasContext}
|
||||
renderInverted={this.props.renderInverted}
|
||||
logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
|
||||
setLogicalSpaceViewportBounds={this.setLogicalSpaceViewportSize}
|
||||
setLogicalSpaceViewportSize={this.setLogicalSpaceViewportSize}
|
||||
searchIsActive={this.props.searchIsActive}
|
||||
searchQuery={this.props.searchQuery}
|
||||
/>
|
||||
{this.renderTooltip()}
|
||||
</div>
|
||||
|
||||
@@ -10,12 +10,11 @@ import {
|
||||
getCanvasContext,
|
||||
createGetColorBucketForFrame,
|
||||
createGetCSSColorForFrame,
|
||||
getProfileWithRecursionFlattened,
|
||||
getFrameToColorBucket,
|
||||
} from '../store/getters'
|
||||
import {FlamechartID} from '../store/flamechart-view-state'
|
||||
import {useAppSelector} from '../store'
|
||||
import {FlamechartWrapper} from './flamechart-wrapper'
|
||||
import {FlamechartWrapper, useDummySearchProps} from './flamechart-wrapper'
|
||||
import {h} from 'preact'
|
||||
import {memo} from 'preact/compat'
|
||||
|
||||
@@ -65,8 +64,6 @@ export const InvertedCallerFlamegraphView = memo((ownProps: FlamechartViewContai
|
||||
if (!callerCallee) throw new Error('callerCallee missing')
|
||||
const {selectedFrame} = callerCallee
|
||||
|
||||
profile = flattenRecursion ? getProfileWithRecursionFlattened(profile) : profile
|
||||
|
||||
const frameToColorBucket = getFrameToColorBucket(profile)
|
||||
const getColorBucketForFrame = createGetColorBucketForFrame(frameToColorBucket)
|
||||
const getCSSColorForFrame = createGetCSSColorForFrame(frameToColorBucket)
|
||||
@@ -93,6 +90,11 @@ export const InvertedCallerFlamegraphView = memo((ownProps: FlamechartViewContai
|
||||
// This overrides the setSelectedNode specified in useFlamechartSettesr
|
||||
setSelectedNode={noop}
|
||||
{...callerCallee.invertedCallerFlamegraph}
|
||||
/*
|
||||
* TODO(jlfwong): When implementing search for the sandwich views,
|
||||
* change these flags
|
||||
* */
|
||||
{...useDummySearchProps()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
+233
-150
@@ -1,17 +1,18 @@
|
||||
import {h, Component, JSX} from 'preact'
|
||||
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 {FontSize, Colors, Sizes, commonStyle} from './style'
|
||||
import {ColorChit} from './color-chit'
|
||||
import {ScrollableListView, ListItem} from './scrollable-list-view'
|
||||
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 {memo} from 'preact/compat'
|
||||
import {useCallback, useMemo, useRef} from 'preact/hooks'
|
||||
import {useCallback, useMemo} from 'preact/hooks'
|
||||
import {fuzzyMatchStrings} from '../lib/fuzzy-find'
|
||||
|
||||
export enum SortField {
|
||||
SYMBOL_NAME,
|
||||
@@ -67,8 +68,13 @@ class SortIcon extends Component<SortIconProps, {}> {
|
||||
}
|
||||
}
|
||||
|
||||
interface ProfileTableRowViewProps {
|
||||
interface ProfileTableRowInfo {
|
||||
frame: Frame
|
||||
matchedRanges: [number, number][] | null
|
||||
}
|
||||
|
||||
interface ProfileTableRowViewProps {
|
||||
info: ProfileTableRowInfo
|
||||
index: number
|
||||
profile: Profile
|
||||
selectedFrame: Frame | null
|
||||
@@ -76,8 +82,33 @@ interface ProfileTableRowViewProps {
|
||||
getCSSColorForFrame: (frame: Frame) => string
|
||||
}
|
||||
|
||||
const ProfileTableRowView = (props: ProfileTableRowViewProps) => {
|
||||
const {frame, profile, index, selectedFrame, setSelectedFrame, getCSSColorForFrame} = props
|
||||
function highlightRanges(
|
||||
text: string,
|
||||
ranges: [number, number][],
|
||||
highlightedClassName: string,
|
||||
): JSX.Element {
|
||||
const spans: ComponentChild[] = []
|
||||
let last = 0
|
||||
for (let range of ranges) {
|
||||
spans.push(text.slice(last, range[0]))
|
||||
spans.push(<span className={highlightedClassName}>{text.slice(range[0], range[1])}</span>)
|
||||
last = range[1]
|
||||
}
|
||||
spans.push(text.slice(last))
|
||||
|
||||
return <span>{spans}</span>
|
||||
}
|
||||
|
||||
const ProfileTableRowView = ({
|
||||
info,
|
||||
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()
|
||||
@@ -107,7 +138,13 @@ const ProfileTableRowView = (props: ProfileTableRowViewProps) => {
|
||||
</td>
|
||||
<td title={frame.file} className={css(style.textCell)}>
|
||||
<ColorChit color={getCSSColorForFrame(frame)} />
|
||||
{frame.name}
|
||||
{matchedRanges
|
||||
? highlightRanges(
|
||||
frame.name,
|
||||
matchedRanges,
|
||||
css(style.matched, selected && style.matchedSelected),
|
||||
)
|
||||
: frame.name}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -120,166 +157,197 @@ interface ProfileTableViewProps {
|
||||
sortMethod: SortMethod
|
||||
setSelectedFrame: (frame: Frame | null) => void
|
||||
setSortMethod: (sortMethod: SortMethod) => void
|
||||
searchQuery: string
|
||||
searchIsActive: boolean
|
||||
}
|
||||
|
||||
export const ProfileTableView = memo((props: ProfileTableViewProps) => {
|
||||
const {
|
||||
export const ProfileTableView = memo(
|
||||
({
|
||||
profile,
|
||||
sortMethod,
|
||||
setSortMethod,
|
||||
selectedFrame,
|
||||
setSelectedFrame,
|
||||
getCSSColorForFrame,
|
||||
} = props
|
||||
searchQuery,
|
||||
searchIsActive,
|
||||
}: ProfileTableViewProps) => {
|
||||
const onSortClick = useCallback(
|
||||
(field: SortField, ev: MouseEvent) => {
|
||||
ev.preventDefault()
|
||||
|
||||
const onSortClick = useCallback(
|
||||
(field: SortField, ev: MouseEvent) => {
|
||||
ev.preventDefault()
|
||||
|
||||
if (sortMethod.field == field) {
|
||||
// Toggle
|
||||
setSortMethod({
|
||||
field,
|
||||
direction:
|
||||
sortMethod.direction === SortDirection.ASCENDING
|
||||
? SortDirection.DESCENDING
|
||||
: SortDirection.ASCENDING,
|
||||
})
|
||||
} else {
|
||||
// Set a sane default
|
||||
switch (field) {
|
||||
case SortField.SYMBOL_NAME: {
|
||||
setSortMethod({field, direction: SortDirection.ASCENDING})
|
||||
break
|
||||
}
|
||||
case SortField.SELF: {
|
||||
setSortMethod({field, direction: SortDirection.DESCENDING})
|
||||
break
|
||||
}
|
||||
case SortField.TOTAL: {
|
||||
setSortMethod({field, direction: SortDirection.DESCENDING})
|
||||
break
|
||||
if (sortMethod.field == field) {
|
||||
// Toggle
|
||||
setSortMethod({
|
||||
field,
|
||||
direction:
|
||||
sortMethod.direction === SortDirection.ASCENDING
|
||||
? SortDirection.DESCENDING
|
||||
: SortDirection.ASCENDING,
|
||||
})
|
||||
} else {
|
||||
// Set a sane default
|
||||
switch (field) {
|
||||
case SortField.SYMBOL_NAME: {
|
||||
setSortMethod({field, direction: SortDirection.ASCENDING})
|
||||
break
|
||||
}
|
||||
case SortField.SELF: {
|
||||
setSortMethod({field, direction: SortDirection.DESCENDING})
|
||||
break
|
||||
}
|
||||
case SortField.TOTAL: {
|
||||
setSortMethod({field, direction: SortDirection.DESCENDING})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[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
|
||||
}
|
||||
}
|
||||
},
|
||||
[sortMethod, setSortMethod],
|
||||
)
|
||||
|
||||
const frameList = useMemo((): Frame[] => {
|
||||
const frameList: Frame[] = []
|
||||
|
||||
profile.forEachFrame(f => frameList.push(f))
|
||||
|
||||
// TODO(jlfwong): This is pretty inefficient to do this on every render, but doesn't
|
||||
// seem to be a bottleneck, so we'll leave it alone.
|
||||
switch (sortMethod.field) {
|
||||
case SortField.SYMBOL_NAME: {
|
||||
sortBy(frameList, f => f.name.toLowerCase())
|
||||
break
|
||||
}
|
||||
case SortField.SELF: {
|
||||
sortBy(frameList, f => f.getSelfWeight())
|
||||
break
|
||||
}
|
||||
case SortField.TOTAL: {
|
||||
sortBy(frameList, f => f.getTotalWeight())
|
||||
break
|
||||
}
|
||||
}
|
||||
if (sortMethod.direction === SortDirection.DESCENDING) {
|
||||
frameList.reverse()
|
||||
}
|
||||
|
||||
return frameList
|
||||
}, [profile, sortMethod])
|
||||
|
||||
const listViewRef = useRef<ScrollableListView | null>(null)
|
||||
const listViewCallback = useCallback(
|
||||
(listView: ScrollableListView | null) => {
|
||||
if (listView === listViewRef.current) return
|
||||
listViewRef.current = listView
|
||||
if (!selectedFrame || !listView) return
|
||||
const index = frameList.indexOf(selectedFrame)
|
||||
if (index === -1) return
|
||||
listView.scrollIndexIntoView(index)
|
||||
},
|
||||
[listViewRef, selectedFrame, frameList],
|
||||
)
|
||||
|
||||
const renderItems = useCallback(
|
||||
(firstIndex: number, lastIndex: number) => {
|
||||
const rows: JSX.Element[] = []
|
||||
|
||||
for (let i = firstIndex; i <= lastIndex; i++) {
|
||||
rows.push(
|
||||
ProfileTableRowView({
|
||||
frame: frameList[i],
|
||||
index: i,
|
||||
profile: profile,
|
||||
selectedFrame: selectedFrame,
|
||||
setSelectedFrame: setSelectedFrame,
|
||||
getCSSColorForFrame: getCSSColorForFrame,
|
||||
}),
|
||||
)
|
||||
if (sortMethod.direction === SortDirection.DESCENDING) {
|
||||
rowList.reverse()
|
||||
}
|
||||
|
||||
return <table className={css(style.tableView)}>{rows}</table>
|
||||
},
|
||||
[frameList, profile, selectedFrame, setSelectedFrame, getCSSColorForFrame],
|
||||
)
|
||||
return rowList
|
||||
}, [profile, sortMethod, searchQuery, searchIsActive])
|
||||
|
||||
const listItems: ListItem[] = frameList.map(f => ({size: Sizes.FRAME_HEIGHT}))
|
||||
const renderItems = useCallback(
|
||||
(firstIndex: number, lastIndex: number) => {
|
||||
const rows: JSX.Element[] = []
|
||||
|
||||
const onTotalClick = useCallback((ev: MouseEvent) => onSortClick(SortField.TOTAL, ev), [
|
||||
onSortClick,
|
||||
])
|
||||
const onSelfClick = useCallback((ev: MouseEvent) => onSortClick(SortField.SELF, ev), [
|
||||
onSortClick,
|
||||
])
|
||||
const onSymbolNameClick = useCallback(
|
||||
(ev: MouseEvent) => onSortClick(SortField.SYMBOL_NAME, ev),
|
||||
[onSortClick],
|
||||
)
|
||||
for (let i = firstIndex; i <= lastIndex; i++) {
|
||||
rows.push(
|
||||
ProfileTableRowView({
|
||||
info: rowList[i],
|
||||
index: i,
|
||||
profile: profile,
|
||||
selectedFrame: selectedFrame,
|
||||
setSelectedFrame: setSelectedFrame,
|
||||
getCSSColorForFrame: getCSSColorForFrame,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css(commonStyle.vbox, style.profileTableView)}>
|
||||
<table className={css(style.tableView)}>
|
||||
<thead className={css(style.tableHeader)}>
|
||||
<tr>
|
||||
<th className={css(style.numericCell)} onClick={onTotalClick}>
|
||||
<SortIcon
|
||||
activeDirection={sortMethod.field === SortField.TOTAL ? sortMethod.direction : null}
|
||||
/>
|
||||
Total
|
||||
</th>
|
||||
<th className={css(style.numericCell)} onClick={onSelfClick}>
|
||||
<SortIcon
|
||||
activeDirection={sortMethod.field === SortField.SELF ? sortMethod.direction : null}
|
||||
/>
|
||||
Self
|
||||
</th>
|
||||
<th className={css(style.textCell)} onClick={onSymbolNameClick}>
|
||||
<SortIcon
|
||||
activeDirection={
|
||||
sortMethod.field === SortField.SYMBOL_NAME ? sortMethod.direction : null
|
||||
}
|
||||
/>
|
||||
Symbol Name
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<ScrollableListView
|
||||
ref={listViewCallback}
|
||||
axis={'y'}
|
||||
items={listItems}
|
||||
className={css(style.scrollView)}
|
||||
renderItems={renderItems}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
if (rows.length === 0) {
|
||||
if (searchIsActive) {
|
||||
rows.push(
|
||||
<tr>
|
||||
<td className={css(style.emptyState)}>
|
||||
No symbol names match query "{searchQuery}".
|
||||
</td>
|
||||
</tr>,
|
||||
)
|
||||
} else {
|
||||
rows.push(
|
||||
<tr>
|
||||
<td className={css(style.emptyState)}>No symbols found.</td>
|
||||
</tr>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return <table className={css(style.tableView)}>{rows}</table>
|
||||
},
|
||||
[
|
||||
rowList,
|
||||
profile,
|
||||
selectedFrame,
|
||||
setSelectedFrame,
|
||||
getCSSColorForFrame,
|
||||
searchIsActive,
|
||||
searchQuery,
|
||||
],
|
||||
)
|
||||
|
||||
const listItems: ListItem[] = useMemo(() => rowList.map(f => ({size: Sizes.FRAME_HEIGHT})), [
|
||||
rowList,
|
||||
])
|
||||
|
||||
const onTotalClick = useCallback((ev: MouseEvent) => onSortClick(SortField.TOTAL, ev), [
|
||||
onSortClick,
|
||||
])
|
||||
const onSelfClick = useCallback((ev: MouseEvent) => onSortClick(SortField.SELF, ev), [
|
||||
onSortClick,
|
||||
])
|
||||
const onSymbolNameClick = useCallback(
|
||||
(ev: MouseEvent) => onSortClick(SortField.SYMBOL_NAME, ev),
|
||||
[onSortClick],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css(commonStyle.vbox, style.profileTableView)}>
|
||||
<table className={css(style.tableView)}>
|
||||
<thead className={css(style.tableHeader)}>
|
||||
<tr>
|
||||
<th className={css(style.numericCell)} onClick={onTotalClick}>
|
||||
<SortIcon
|
||||
activeDirection={
|
||||
sortMethod.field === SortField.TOTAL ? sortMethod.direction : null
|
||||
}
|
||||
/>
|
||||
Total
|
||||
</th>
|
||||
<th className={css(style.numericCell)} onClick={onSelfClick}>
|
||||
<SortIcon
|
||||
activeDirection={
|
||||
sortMethod.field === SortField.SELF ? sortMethod.direction : null
|
||||
}
|
||||
/>
|
||||
Self
|
||||
</th>
|
||||
<th className={css(style.textCell)} onClick={onSymbolNameClick}>
|
||||
<SortIcon
|
||||
activeDirection={
|
||||
sortMethod.field === SortField.SYMBOL_NAME ? sortMethod.direction : null
|
||||
}
|
||||
/>
|
||||
Symbol Name
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<ScrollableListView
|
||||
axis={'y'}
|
||||
items={listItems}
|
||||
className={css(style.scrollView)}
|
||||
renderItems={renderItems}
|
||||
initialIndexInView={
|
||||
selectedFrame == null ? null : rowList.findIndex(f => f.frame === selectedFrame)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const style = StyleSheet.create({
|
||||
profileTableView: {
|
||||
@@ -289,6 +357,7 @@ const style = StyleSheet.create({
|
||||
scrollView: {
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
flexGrow: 1,
|
||||
},
|
||||
tableView: {
|
||||
width: '100%',
|
||||
@@ -347,6 +416,16 @@ const style = StyleSheet.create({
|
||||
background: Colors.GREEN,
|
||||
right: 0,
|
||||
},
|
||||
matched: {
|
||||
borderBottom: `2px solid ${Colors.BLACK}`,
|
||||
},
|
||||
matchedSelected: {
|
||||
borderColor: Colors.WHITE,
|
||||
},
|
||||
emptyState: {
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
})
|
||||
|
||||
interface ProfileTableViewContainerProps {
|
||||
@@ -372,6 +451,8 @@ export const ProfileTableViewContainer = memo((ownProps: ProfileTableViewContain
|
||||
[index],
|
||||
)
|
||||
const setSortMethod = useActionCreator(setTableSortMethod, [])
|
||||
const searchIsActive = useAppSelector(state => state.searchIsActive, [])
|
||||
const searchQuery = useAppSelector(state => state.searchQuery, [])
|
||||
|
||||
return (
|
||||
<ProfileTableView
|
||||
@@ -381,6 +462,8 @@ export const ProfileTableViewContainer = memo((ownProps: ProfileTableViewContain
|
||||
sortMethod={tableSortMethod}
|
||||
setSelectedFrame={setSelectedFrame}
|
||||
setSortMethod={setSortMethod}
|
||||
searchIsActive={searchIsActive}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -10,7 +10,9 @@ 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} from '../lib/preact-redux'
|
||||
import {useDispatch, useActionCreator} from '../lib/preact-redux'
|
||||
import {SearchView} from './search-view'
|
||||
import {useAppSelector} from '../store'
|
||||
|
||||
interface SandwichViewProps {
|
||||
selectedFrame: Frame | null
|
||||
@@ -18,6 +20,10 @@ 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> {
|
||||
@@ -39,7 +45,13 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {selectedFrame} = this.props
|
||||
const {
|
||||
selectedFrame,
|
||||
searchIsActive,
|
||||
setSearchIsActive,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
} = this.props
|
||||
let flamegraphViews: JSX.Element | null = null
|
||||
|
||||
if (selectedFrame) {
|
||||
@@ -72,6 +84,12 @@ 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}
|
||||
/>
|
||||
</div>
|
||||
{flamegraphViews}
|
||||
</div>
|
||||
@@ -81,6 +99,7 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
|
||||
|
||||
const style = StyleSheet.create({
|
||||
tableView: {
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
},
|
||||
panZoomViewWraper: {
|
||||
@@ -124,6 +143,8 @@ interface SandwichViewContainerProps {
|
||||
glCanvas: HTMLCanvasElement
|
||||
}
|
||||
|
||||
const {setSearchQuery, setSearchIsActive} = actions
|
||||
|
||||
export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps) => {
|
||||
const {activeProfileState, glCanvas} = ownProps
|
||||
const {sandwichViewState, index} = activeProfileState
|
||||
@@ -141,6 +162,7 @@ export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps)
|
||||
},
|
||||
[dispatch, index],
|
||||
)
|
||||
|
||||
return (
|
||||
<SandwichView
|
||||
activeProfileState={activeProfileState}
|
||||
@@ -148,6 +170,10 @@ export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps)
|
||||
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, [])}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,57 +1,76 @@
|
||||
// A simple implementation of an efficient scrolling list view which
|
||||
// renders only items within the viewport + a couple extra items.
|
||||
|
||||
import {h, Component, JSX} from 'preact'
|
||||
import {h, JSX} from 'preact'
|
||||
import {useState, useCallback, useRef, useMemo, useEffect} from 'preact/hooks'
|
||||
|
||||
export interface ListItem {
|
||||
size: number
|
||||
}
|
||||
|
||||
interface RangeResult {
|
||||
firstVisibleIndex: number
|
||||
lastVisibleIndex: number
|
||||
invisiblePrefixSize: number
|
||||
}
|
||||
|
||||
interface ScrollableListViewProps {
|
||||
items: ListItem[]
|
||||
axis: 'x' | 'y'
|
||||
renderItems: (firstVisibleIndex: number, lastVisibleIndex: number) => JSX.Element | JSX.Element[]
|
||||
className?: string
|
||||
initialIndexInView?: number | null
|
||||
}
|
||||
|
||||
interface ScrollableListViewState {
|
||||
firstVisibleIndex: number | null
|
||||
lastVisibleIndex: number | null
|
||||
invisiblePrefixSize: number | null
|
||||
viewportSize: number | null
|
||||
cachedTotalSize: number
|
||||
}
|
||||
export const ScrollableListView = ({
|
||||
items,
|
||||
axis,
|
||||
renderItems,
|
||||
className,
|
||||
initialIndexInView,
|
||||
}: ScrollableListViewProps) => {
|
||||
const [viewportSize, setViewportSize] = useState<number | null>(null)
|
||||
const [viewportScrollOffset, setViewportScrollOffset] = useState<number>(0)
|
||||
|
||||
export class ScrollableListView extends Component<
|
||||
ScrollableListViewProps,
|
||||
ScrollableListViewState
|
||||
> {
|
||||
constructor(props: ScrollableListViewProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
firstVisibleIndex: null,
|
||||
lastVisibleIndex: null,
|
||||
invisiblePrefixSize: null,
|
||||
viewportSize: null,
|
||||
cachedTotalSize: props.items.reduce((a, b) => a + b.size, 0),
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const widthOrHeight = axis === 'x' ? 'width' : 'height'
|
||||
const leftOrTop = axis === 'x' ? 'left' : 'top'
|
||||
const scrollLeftOrScrollTop = axis === 'x' ? 'scrollLeft' : 'scrollTop'
|
||||
|
||||
// This is kind of a weird hack, but I'm not sure what the better of doing something like this is.
|
||||
const offset = initialIndexInView
|
||||
? items.reduce((a, b, i) => (i < initialIndexInView ? a + b.size : a), 0)
|
||||
: 0
|
||||
const initialScroll = useRef<number | null>(offset)
|
||||
|
||||
const viewportCallback = useCallback(
|
||||
(viewport: HTMLDivElement | null) => {
|
||||
if (viewport) {
|
||||
requestAnimationFrame(() => {
|
||||
setViewportSize(viewport.getBoundingClientRect()[widthOrHeight])
|
||||
if (initialScroll.current != null) {
|
||||
viewport.scrollTo({[leftOrTop]: initialScroll.current})
|
||||
initialScroll.current = null
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setViewportSize(null)
|
||||
}
|
||||
viewportRef.current = viewport
|
||||
},
|
||||
[setViewportSize, widthOrHeight, leftOrTop],
|
||||
)
|
||||
|
||||
const rangeResult: RangeResult | null = useMemo(() => {
|
||||
if (viewportRef.current == null || viewportSize == null || viewportScrollOffset == null) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private viewport: HTMLDivElement | null = null
|
||||
private viewportRef = (viewport: Element | null) => {
|
||||
this.viewport = (viewport as HTMLDivElement) || null
|
||||
}
|
||||
|
||||
private recomputeVisibleIndices(props: ScrollableListViewProps) {
|
||||
if (!this.viewport) return
|
||||
const {items} = props
|
||||
|
||||
const viewportSize = this.viewport.getBoundingClientRect().height
|
||||
|
||||
// We render items up to a quarter viewport height outside of the
|
||||
// viewport both above and below to prevent flickering.
|
||||
const minY = this.viewport.scrollTop - viewportSize / 4
|
||||
const maxY = this.viewport.scrollTop + viewportSize + viewportSize / 4
|
||||
const minY = viewportScrollOffset - viewportSize / 4
|
||||
const maxY = viewportScrollOffset + viewportSize + viewportSize / 4
|
||||
|
||||
let total = 0
|
||||
let invisiblePrefixSize = 0
|
||||
@@ -77,62 +96,54 @@ export class ScrollableListView extends Component<
|
||||
}
|
||||
|
||||
const lastVisibleIndex = Math.min(i, items.length - 1)
|
||||
this.setState({invisiblePrefixSize, firstVisibleIndex, lastVisibleIndex})
|
||||
}
|
||||
|
||||
private pendingScroll = 0
|
||||
public scrollIndexIntoView(index: number) {
|
||||
this.pendingScroll = this.props.items.reduce((sum, cur, i) => {
|
||||
if (i >= index) return sum
|
||||
return sum + cur.size
|
||||
}, 0)
|
||||
}
|
||||
private applyPendingScroll() {
|
||||
if (!this.viewport) return
|
||||
|
||||
const leftOrTop = this.props.axis === 'y' ? 'top' : 'left'
|
||||
this.viewport.scrollTo({
|
||||
[leftOrTop]: this.pendingScroll,
|
||||
})
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps: ScrollableListViewProps) {
|
||||
if (this.props.items !== nextProps.items) {
|
||||
this.recomputeVisibleIndices(nextProps)
|
||||
return {
|
||||
firstVisibleIndex,
|
||||
lastVisibleIndex,
|
||||
invisiblePrefixSize,
|
||||
}
|
||||
}
|
||||
}, [viewportSize, viewportScrollOffset, items])
|
||||
|
||||
componentDidMount() {
|
||||
this.applyPendingScroll()
|
||||
this.recomputeVisibleIndices(this.props)
|
||||
window.addEventListener('resize', this.onWindowResize)
|
||||
}
|
||||
const totalSize = useMemo(() => items.reduce((a, b) => a + b.size, 0), [items])
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.onWindowResize)
|
||||
}
|
||||
const onViewportScroll = useCallback(() => {
|
||||
if (viewportRef.current != null) {
|
||||
setViewportScrollOffset(viewportRef.current[scrollLeftOrScrollTop])
|
||||
}
|
||||
}, [scrollLeftOrScrollTop])
|
||||
|
||||
onWindowResize = () => {
|
||||
this.recomputeVisibleIndices(this.props)
|
||||
}
|
||||
useEffect(() => {
|
||||
const resizeListener = () => {
|
||||
if (viewportRef.current != null) {
|
||||
setViewportSize(viewportRef.current.getBoundingClientRect()[widthOrHeight])
|
||||
}
|
||||
}
|
||||
|
||||
onViewportScroll = (ev: UIEvent) => {
|
||||
this.recomputeVisibleIndices(this.props)
|
||||
}
|
||||
window.addEventListener('resize', resizeListener)
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeListener)
|
||||
}
|
||||
}, [widthOrHeight])
|
||||
|
||||
render() {
|
||||
const {cachedTotalSize, firstVisibleIndex, lastVisibleIndex, invisiblePrefixSize} = this.state
|
||||
const visibleItems = useMemo(() => {
|
||||
return rangeResult
|
||||
? renderItems(rangeResult.firstVisibleIndex, rangeResult.lastVisibleIndex)
|
||||
: null
|
||||
}, [renderItems, rangeResult])
|
||||
|
||||
const content = useMemo(() => {
|
||||
return (
|
||||
<div className={this.props.className} ref={this.viewportRef} onScroll={this.onViewportScroll}>
|
||||
<div style={{height: cachedTotalSize}}>
|
||||
<div style={{transform: `translateY(${invisiblePrefixSize}px)`}}>
|
||||
{firstVisibleIndex != null &&
|
||||
lastVisibleIndex != null &&
|
||||
this.props.renderItems(firstVisibleIndex, lastVisibleIndex)}
|
||||
</div>
|
||||
<div style={{height: totalSize}}>
|
||||
<div style={{transform: `translateY(${rangeResult?.invisiblePrefixSize || 0}px)`}}>
|
||||
{visibleItems}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}, [rangeResult?.invisiblePrefixSize, visibleItems, totalSize])
|
||||
|
||||
return (
|
||||
<div className={className} ref={viewportCallback} onScroll={onViewportScroll}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import {StyleSheet, css} from 'aphrodite'
|
||||
import {h} from 'preact'
|
||||
import {useCallback, useRef, useEffect} from 'preact/hooks'
|
||||
import {memo} from 'preact/compat'
|
||||
import {Sizes, Colors, FontSize} from './style'
|
||||
|
||||
function stopPropagation(ev: Event) {
|
||||
ev.stopPropagation()
|
||||
}
|
||||
|
||||
export interface SearchViewProps {
|
||||
searchQuery: string
|
||||
searchIsActive: boolean
|
||||
|
||||
setSearchQuery: (query: string) => void
|
||||
setSearchIsActive: (active: boolean) => void
|
||||
}
|
||||
|
||||
export const SearchView = memo(
|
||||
({searchQuery, setSearchQuery, searchIsActive, setSearchIsActive}: SearchViewProps) => {
|
||||
const onInput = useCallback(
|
||||
(ev: Event) => {
|
||||
const value = (ev.target as HTMLInputElement).value
|
||||
setSearchQuery(value)
|
||||
},
|
||||
[setSearchQuery],
|
||||
)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(ev: KeyboardEvent) => {
|
||||
ev.stopPropagation()
|
||||
|
||||
// Hitting Esc should close the search box
|
||||
if (ev.key === 'Escape') {
|
||||
setSearchIsActive(false)
|
||||
}
|
||||
|
||||
if (ev.key == 'f' && (ev.metaKey || ev.ctrlKey)) {
|
||||
if (inputRef.current) {
|
||||
// If the input is already focused, select all
|
||||
inputRef.current.select()
|
||||
}
|
||||
|
||||
// It seems like when an input is focused, the browser find menu pops
|
||||
// up without this line. It seems like it's not sufficient to only
|
||||
// preventDefault in the window keydown handler.
|
||||
ev.preventDefault()
|
||||
}
|
||||
},
|
||||
[setSearchIsActive],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const onWindowKeyDown = (ev: KeyboardEvent) => {
|
||||
// Cmd+F or Ctrl+F open the search box
|
||||
if (ev.key == 'f' && (ev.metaKey || ev.ctrlKey)) {
|
||||
// Prevent the browser's search menu from appearing
|
||||
ev.preventDefault()
|
||||
|
||||
if (inputRef.current) {
|
||||
// If the search box is already open, then re-select it immediately.
|
||||
inputRef.current.select()
|
||||
} else {
|
||||
// Otherwise, focus the search, then focus the input on the next
|
||||
// frame, when the search box should have mounted.
|
||||
setSearchIsActive(true)
|
||||
requestAnimationFrame(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.select()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onWindowKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onWindowKeyDown)
|
||||
}
|
||||
}, [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}
|
||||
/>
|
||||
|
||||
<svg
|
||||
onClick={close}
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.99999 4.16217L11.6427 10.8048M11.6427 4.16217L4.99999 10.8048"
|
||||
stroke="#BDBDBD"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const style = StyleSheet.create({
|
||||
searchView: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 10,
|
||||
height: Sizes.TOOLBAR_HEIGHT,
|
||||
width: 150,
|
||||
borderWidth: 2,
|
||||
borderColor: Colors.BLACK,
|
||||
borderStyle: 'solid',
|
||||
fontSize: FontSize.LABEL,
|
||||
boxSizing: 'border-box',
|
||||
background: Colors.DARK_GRAY,
|
||||
color: Colors.WHITE,
|
||||
display: 'flex',
|
||||
},
|
||||
input: {
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
fontSize: FontSize.LABEL,
|
||||
flex: 1,
|
||||
color: Colors.WHITE,
|
||||
':focus': {
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
},
|
||||
'::selection': {
|
||||
color: Colors.WHITE,
|
||||
background: Colors.DARK_BLUE,
|
||||
},
|
||||
},
|
||||
icon: {
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle',
|
||||
paddingTop: '0px',
|
||||
margin: '0 2px 0 4px',
|
||||
},
|
||||
})
|
||||
@@ -22,6 +22,7 @@ export enum Colors {
|
||||
PALE_DARK_BLUE = '#8EB7ED',
|
||||
GREEN = '#6FCF97',
|
||||
TRANSPARENT_GREEN = 'rgba(111, 207, 151, 0.2)',
|
||||
YELLOW = '#FEDC62',
|
||||
}
|
||||
|
||||
export enum Sizes {
|
||||
|
||||
@@ -108,6 +108,19 @@ function ToolbarCenterContent(props: ToolbarProps): JSX.Element {
|
||||
}
|
||||
}, [setProfileSelectShown])
|
||||
|
||||
useEffect(() => {
|
||||
const onWindowKeyPress = (ev: KeyboardEvent) => {
|
||||
if (ev.key === 't') {
|
||||
ev.preventDefault()
|
||||
setProfileSelectShown(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keypress', onWindowKeyPress)
|
||||
return () => {
|
||||
window.removeEventListener('keypress', onWindowKeyPress)
|
||||
}
|
||||
}, [setProfileSelectShown])
|
||||
|
||||
if (activeProfileState && profileGroup && profiles) {
|
||||
if (profileGroup.profiles.length === 1) {
|
||||
return <Fragment>{activeProfileState.profile.getName()}</Fragment>
|
||||
|
||||
Reference in New Issue
Block a user