Skip to content

Commit

Permalink
Timeline search
Browse files Browse the repository at this point in the history
Refactor SearchInput component (used in Components tree) to be generic DevTools component with two uses: ComponentSearchInput and TimelineSearchInput.

Refactored Timeline Suspense to more closely match other, newer Suspense patterns (e.g. inspect component, named hooks) and colocated Susepnse code in timelineCache file.

Add search by component name functionality to the Timeline. For now, searching zooms in to the component measure and you can step through each time it rendered using the next/previous arrows.
  • Loading branch information
Brian Vaughn committed Nov 22, 2021
1 parent 149b420 commit 7531a1d
Show file tree
Hide file tree
Showing 18 changed files with 549 additions and 90 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

import * as React from 'react';
import {useContext} from 'react';
import {TreeDispatcherContext, TreeStateContext} from './TreeContext';

import SearchInput from '../SearchInput';

type Props = {||};

export default function ComponentSearchInput(props: Props) {
const {searchIndex, searchResults, searchText} = useContext(TreeStateContext);
const dispatch = useContext(TreeDispatcherContext);

const search = text => dispatch({type: 'SET_SEARCH_TEXT', payload: text});
const goToNextResult = () => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'});
const goToPreviousResult = () =>
dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'});

return (
<SearchInput
goToNextResult={goToNextResult}
goToPreviousResult={goToPreviousResult}
placeholder="Search (text or /regex/)"
search={search}
searchIndex={searchIndex}
searchResultsCount={searchResults.length}
searchText={searchText}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {BridgeContext, StoreContext, OptionsContext} from '../context';
import Element from './Element';
import InspectHostNodesToggle from './InspectHostNodesToggle';
import OwnersStack from './OwnersStack';
import SearchInput from './SearchInput';
import ComponentSearchInput from './ComponentSearchInput';
import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle';
import SelectedTreeHighlight from './SelectedTreeHighlight';
import TreeFocusedContext from './TreeFocusedContext';
Expand Down Expand Up @@ -343,7 +343,7 @@ export default function Tree(props: Props) {
</Fragment>
)}
<Suspense fallback={<Loading />}>
{ownerID !== null ? <OwnersStack /> : <SearchInput />}
{ownerID !== null ? <OwnersStack /> : <ComponentSearchInput />}
</Suspense>
{showInlineWarningsAndErrors &&
ownerID === null &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -829,7 +829,7 @@ type Props = {|
defaultSelectedElementIndex?: ?number,
|};

// TODO Remove TreeContextController wrapper element once global ConsearchText.write API exists.
// TODO Remove TreeContextController wrapper element once global Context.write API exists.
function TreeContextController({
children,
defaultInspectedElementID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,19 @@ export default function ClearProfilingDataButton() {
const {didRecordCommits, isProfiling, selectedTabID} = useContext(
ProfilerContext,
);
const {clearTimelineData, timelineData} = useContext(TimelineContext);
const {file, setFile} = useContext(TimelineContext);
const {profilerStore} = store;

let doesHaveData = false;
if (selectedTabID === 'timeline') {
doesHaveData = timelineData !== null;
doesHaveData = file !== null;
} else {
doesHaveData = didRecordCommits;
}

const clear = () => {
if (selectedTabID === 'timeline') {
clearTimelineData();
setFile(null);
} else {
profilerStore.clear();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,9 @@
.Link {
color: var(--color-button);
}

.TimlineSearchInputContainer {
flex: 1 1;
display: flex;
align-items: center;
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views
import {SettingsModalContextController} from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContext';
import portaledContent from '../portaledContent';
import {StoreContext} from '../context';
import {TimelineContext} from 'react-devtools-timeline/src/TimelineContext';

import styles from './Profiler.css';

Expand All @@ -43,19 +44,19 @@ function Profiler(_: {||}) {
supportsProfiling,
} = useContext(ProfilerContext);

const {searchInputContainerRef} = useContext(TimelineContext);

const {supportsTimeline} = useContext(StoreContext);

let isLegacyProfilerSelected = false;
const isLegacyProfilerSelected = selectedTabID !== 'timeline';

let view = null;
if (didRecordCommits || selectedTabID === 'timeline') {
switch (selectedTabID) {
case 'flame-chart':
isLegacyProfilerSelected = true;
view = <CommitFlamegraph />;
break;
case 'ranked-chart':
isLegacyProfilerSelected = true;
view = <CommitRanked />;
break;
case 'timeline':
Expand Down Expand Up @@ -121,6 +122,12 @@ function Profiler(_: {||}) {
/>
<RootSelector />
<div className={styles.Spacer} />
{!isLegacyProfilerSelected && (
<div
ref={searchInputContainerRef}
className={styles.TimlineSearchInputContainer}
/>
)}
<SettingsModalContextToggle />
{isLegacyProfilerSelected && didRecordCommits && (
<Fragment>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default function ProfilingImportExportButtons() {
const {isProfiling, profilingData, rootID, selectedTabID} = useContext(
ProfilerContext,
);
const {importTimelineData} = useContext(TimelineContext);
const {setFile} = useContext(TimelineContext);
const store = useContext(StoreContext);
const {profilerStore} = store;

Expand Down Expand Up @@ -111,7 +111,8 @@ export default function ProfilingImportExportButtons() {
const importTimelineDataWrapper = event => {
const input = inputRef.current;
if (input !== null && input.files.length > 0) {
importTimelineData(input.files[0]);
const file = input.files[0];
setFile(file);
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,52 +8,56 @@
*/

import * as React from 'react';
import {useCallback, useContext, useEffect, useRef} from 'react';
import {TreeDispatcherContext, TreeStateContext} from './TreeContext';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
import Icon from '../Icon';
import {useEffect, useRef} from 'react';
import Button from './Button';
import ButtonIcon from './ButtonIcon';
import Icon from './Icon';

import styles from './SearchInput.css';

type Props = {||};

export default function SearchInput(props: Props) {
const {searchIndex, searchResults, searchText} = useContext(TreeStateContext);
const dispatch = useContext(TreeDispatcherContext);
type Props = {|
goToNextResult: () => void,
goToPreviousResult: () => void,
placeholder: string,
search: (text: string) => void,
searchIndex: number,
searchResultsCount: number,
searchText: string,
|};

export default function SearchInput({
goToNextResult,
goToPreviousResult,
placeholder,
search,
searchIndex,
searchResultsCount,
searchText,
}: Props) {
const inputRef = useRef<HTMLInputElement | null>(null);

const handleTextChange = useCallback(
({currentTarget}) =>
dispatch({type: 'SET_SEARCH_TEXT', payload: currentTarget.value}),
[dispatch],
);
const resetSearch = useCallback(
() => dispatch({type: 'SET_SEARCH_TEXT', payload: ''}),
[dispatch],
);
const resetSearch = () => search('');

const handleInputKeyPress = useCallback(
({key, shiftKey}) => {
if (key === 'Enter') {
if (shiftKey) {
dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'});
} else {
dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'});
}
const handleChange = ({currentTarget}) => {
search(currentTarget.value);
};
const handleKeyPress = ({key, shiftKey}) => {
if (key === 'Enter') {
if (shiftKey) {
goToPreviousResult();
} else {
goToNextResult();
}
},
[dispatch],
);
}
};

// Auto-focus search input
useEffect(() => {
if (inputRef.current === null) {
return () => {};
}

const handleWindowKey = (event: KeyboardEvent) => {
const handleKeyDown = (event: KeyboardEvent) => {
const {key, metaKey} = event;
if (key === 'f' && metaKey) {
if (inputRef.current !== null) {
Expand All @@ -68,33 +72,33 @@ export default function SearchInput(props: Props) {
// Here we use portals to render individual tabs (e.g. Profiler),
// and the root document might belong to a different window.
const ownerDocument = inputRef.current.ownerDocument;
ownerDocument.addEventListener('keydown', handleWindowKey);
ownerDocument.addEventListener('keydown', handleKeyDown);

return () => ownerDocument.removeEventListener('keydown', handleWindowKey);
return () => ownerDocument.removeEventListener('keydown', handleKeyDown);
}, [inputRef]);

return (
<div className={styles.SearchInput}>
<Icon className={styles.InputIcon} type="search" />
<input
className={styles.Input}
onChange={handleTextChange}
onKeyPress={handleInputKeyPress}
placeholder="Search (text or /regex/)"
onChange={handleChange}
onKeyPress={handleKeyPress}
placeholder={placeholder}
ref={inputRef}
value={searchText}
/>
{!!searchText && (
<React.Fragment>
<span className={styles.IndexLabel}>
{Math.min(searchIndex + 1, searchResults.length)} |{' '}
{searchResults.length}
{Math.min(searchIndex + 1, searchResultsCount)} |{' '}
{searchResultsCount}
</span>
<div className={styles.LeftVRule} />
<Button
className={styles.IconButton}
disabled={!searchText}
onClick={() => dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'})}
onClick={goToPreviousResult}
title={
<React.Fragment>
Scroll to previous search result (<kbd>Shift</kbd> +{' '}
Expand All @@ -106,7 +110,7 @@ export default function SearchInput(props: Props) {
<Button
className={styles.IconButton}
disabled={!searchText}
onClick={() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'})}
onClick={goToNextResult}
title={
<React.Fragment>
Scroll to next search result (<kbd>Enter</kbd>)
Expand Down
28 changes: 28 additions & 0 deletions packages/react-devtools-timeline/src/CanvasPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import ContextMenuItem from 'react-devtools-shared/src/devtools/ContextMenu/Cont
import useContextMenu from 'react-devtools-shared/src/devtools/ContextMenu/useContextMenu';
import {getBatchRange} from './utils/getBatchRange';
import {MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL} from './view-base/constants';
import {TimelineSearchContext} from './TimelineSearchContext';

import styles from './CanvasPage.css';

Expand Down Expand Up @@ -170,6 +171,32 @@ function AutoSizedCanvas({
[],
);

const {searchIndex, searchRegExp, searchResults} = useContext(
TimelineSearchContext,
);

useLayoutEffect(() => {
viewState.updateSearchRegExpState(searchRegExp);

const componentMeasure =
searchResults.length > 0 ? searchResults[searchIndex] : null;
if (componentMeasure != null) {
const scrollState = moveStateToRange({
state: viewState.horizontalScrollState,
rangeStart: componentMeasure.timestamp,
rangeEnd: componentMeasure.timestamp + componentMeasure.duration,
contentLength: data.duration,
minContentLength: data.duration * MIN_ZOOM_LEVEL,
maxContentLength: data.duration * MAX_ZOOM_LEVEL,
containerLength: width,
});

viewState.updateHorizontalScrollState(scrollState);
}

surfaceRef.current.displayIfNeeded();
}, [searchIndex, searchRegExp, searchResults, viewState]);

const surfaceRef = useRef(new Surface(resetHoveredEvent));
const userTimingMarksViewRef = useRef(null);
const nativeEventsViewRef = useRef(null);
Expand Down Expand Up @@ -334,6 +361,7 @@ function AutoSizedCanvas({
surface,
defaultFrame,
data,
viewState,
);
componentMeasuresViewRef.current = componentMeasuresView;
componentMeasuresViewWrapper = createViewHelper(
Expand Down
Loading

0 comments on commit 7531a1d

Please sign in to comment.