mirror of
https://github.com/jlfwong/speedscope.git
synced 2024-11-26 07:35:55 +03:00
Introduce filtering via Ctrl+F/Cmd+F into the sandwich view (#293)
This is the first step towards fixing #38. I started with the easiest part from a UI-paradigm perspective, and also the place that's the most confusing that search doesn't work. Before this PR, browers' Cmd+F/Ctrl+F would *look* like it worked in the Sandwich view, but they wouldn't work fully because the view in the sandwich view is a virtualized table, meaning that it doesn't put all of the rows in the DOM. Instead, it only renders enough to fill the viewport to make rendering much faster. Here's what the changes from this PR look like in action: ![Kapture 2020-07-12 at 23 17 33](https://user-images.githubusercontent.com/150329/87276802-ef2b8780-c495-11ea-9856-9c834ea7f028.gif) Before closing #38, I'll be adding search functionality to the flamechart views too.
This commit is contained in:
parent
9ed1eb192c
commit
668bb032ba
@ -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>(
|
||||
|
@ -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
|
||||
|
||||
|
@ -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: {
|
||||
@ -347,6 +415,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 +450,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 +461,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,77 @@
|
||||
// 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) {
|
||||
console.log('executing initial scroll to ', initialScroll.current)
|
||||
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 +97,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>
|
||||
)
|
||||
}
|
||||
|
146
src/views/search-view.tsx
Normal file
146
src/views/search-view.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
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()
|
||||
}
|
||||
|
||||
interface SearchViewProps {
|
||||
searchQuery: string
|
||||
searchIsActive: boolean
|
||||
|
||||
setSearchQuery: (query: string | null) => 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)
|
||||
}
|
||||
},
|
||||
[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.
|
||||
inputRef.current.select()
|
||||
} else {
|
||||
setSearchIsActive(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onWindowKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onWindowKeyDown)
|
||||
}
|
||||
}, [setSearchIsActive])
|
||||
|
||||
const focusInput = useCallback((node: HTMLInputElement | null) => {
|
||||
if (node) {
|
||||
requestAnimationFrame(() => {
|
||||
node.select()
|
||||
})
|
||||
}
|
||||
inputRef.current = node
|
||||
}, [])
|
||||
|
||||
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={focusInput}
|
||||
/>
|
||||
|
||||
<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',
|
||||
},
|
||||
})
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user