diff --git a/src/store/actions.ts b/src/store/actions.ts index 2b379df..68fc5e8 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -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('setProfileGroup') - - // Set the index into the profile group to view export const setProfileIndexToView = actionCreator('setProfileIndexToView') - export const setGLCanvas = actionCreator('setGLCanvas') - - // Set which top-level view should be displayed export const setViewMode = actionCreator('setViewMode') - - // Set whether or not recursion should be flattened when viewing flamegraphs export const setFlattenRecursion = actionCreator('setFlattenRecursion') - - // Set whether a file drag is currently active. Used to indicate that the - // application is a valid drop target. + export const setSearchQuery = actionCreator('setSearchQuery') + export const setSearchIsActive = actionCreator('setSearchIsActive') export const setDragActive = actionCreator('setDragActive') - - // Set whether the application is currently in a loading state. Used to - // display a loading progress bar. export const setLoading = actionCreator('setLoading') - - // Set whether the application is in an errored state. export const setError = actionCreator('setError') - - // Set whether parameters defined by the URL encoded k=v pairs after the # in the URL export const setHashParams = actionCreator('setHashParams') export namespace sandwichView { - // Set the table sorting method used for the sandwich view. export const setTableSortMethod = actionCreator('sandwichView.setTableSortMethod') export const setSelectedFrame = actionCreatorWithIndex( diff --git a/src/store/index.ts b/src/store/index.ts index da7f95e..aca59f4 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -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(actions.setViewMode, ViewMode.CHRONO_FLAME_CHART), + searchQuery: setter(actions.setSearchQuery, ''), + searchIsActive: setter(actions.setSearchIsActive, false), + glCanvas: setter(actions.setGLCanvas, null), dragActive: setter(actions.setDragActive, false), diff --git a/src/store/profiles-state.ts b/src/store/profiles-state.ts index 9d2f579..9f1d0b2 100644 --- a/src/store/profiles-state.ts +++ b/src/store/profiles-state.ts @@ -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 diff --git a/src/views/profile-table-view.tsx b/src/views/profile-table-view.tsx index 752c010..ed04d35 100644 --- a/src/views/profile-table-view.tsx +++ b/src/views/profile-table-view.tsx @@ -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 { } } -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({text.slice(range[0], range[1])}) + last = range[1] + } + spans.push(text.slice(last)) + + return {spans} +} + +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) => { - {frame.name} + {matchedRanges + ? highlightRanges( + frame.name, + matchedRanges, + css(style.matched, selected && style.matchedSelected), + ) + : frame.name} ) @@ -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(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 {rows}
- }, - [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 ( -
- - - - - - - - -
- - Total - - - Self - - - Symbol Name -
- -
- ) -}) + if (rows.length === 0) { + if (searchIsActive) { + rows.push( + + + No symbol names match query "{searchQuery}". + + , + ) + } else { + rows.push( + + No symbols found. + , + ) + } + } + + return {rows}
+ }, + [ + 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 ( +
+ + + + + + + + +
+ + Total + + + Self + + + Symbol Name +
+ f.frame === selectedFrame) + } + /> +
+ ) + }, +) 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 ( ) }) diff --git a/src/views/sandwich-view.tsx b/src/views/sandwich-view.tsx index 7eb54d7..c0ca0c7 100644 --- a/src/views/sandwich-view.tsx +++ b/src/views/sandwich-view.tsx @@ -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 { @@ -39,7 +45,13 @@ class SandwichView extends StatelessComponent { } 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 {
+
{flamegraphViews}
@@ -81,6 +99,7 @@ class SandwichView extends StatelessComponent { 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 ( state.searchQuery, [])} + setSearchQuery={useActionCreator(setSearchQuery, [])} + searchIsActive={useAppSelector(state => state.searchIsActive, [])} + setSearchIsActive={useActionCreator(setSearchIsActive, [])} /> ) }) diff --git a/src/views/scrollable-list-view.tsx b/src/views/scrollable-list-view.tsx index 8636d0a..23d535a 100644 --- a/src/views/scrollable-list-view.tsx +++ b/src/views/scrollable-list-view.tsx @@ -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(null) + const [viewportScrollOffset, setViewportScrollOffset] = useState(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(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(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 ( -
-
-
- {firstVisibleIndex != null && - lastVisibleIndex != null && - this.props.renderItems(firstVisibleIndex, lastVisibleIndex)} -
+
+
+ {visibleItems}
) - } + }, [rangeResult?.invisiblePrefixSize, visibleItems, totalSize]) + + return ( +
+ {content} +
+ ) } diff --git a/src/views/search-view.tsx b/src/views/search-view.tsx new file mode 100644 index 0000000..c63c8b5 --- /dev/null +++ b/src/views/search-view.tsx @@ -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(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 ( +
+ 🔍 + + + + + +
+ ) + }, +) + +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', + }, +}) diff --git a/src/views/toolbar.tsx b/src/views/toolbar.tsx index b9efe5b..26e95da 100644 --- a/src/views/toolbar.tsx +++ b/src/views/toolbar.tsx @@ -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 {activeProfileState.profile.getName()}