Skip to content

Commit 7531a1d

Browse files
author
Brian Vaughn
committed
Timeline search
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.
1 parent 149b420 commit 7531a1d

18 files changed

+549
-90
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import * as React from 'react';
11+
import {useContext} from 'react';
12+
import {TreeDispatcherContext, TreeStateContext} from './TreeContext';
13+
14+
import SearchInput from '../SearchInput';
15+
16+
type Props = {||};
17+
18+
export default function ComponentSearchInput(props: Props) {
19+
const {searchIndex, searchResults, searchText} = useContext(TreeStateContext);
20+
const dispatch = useContext(TreeDispatcherContext);
21+
22+
const search = text => dispatch({type: 'SET_SEARCH_TEXT', payload: text});
23+
const goToNextResult = () => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'});
24+
const goToPreviousResult = () =>
25+
dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'});
26+
27+
return (
28+
<SearchInput
29+
goToNextResult={goToNextResult}
30+
goToPreviousResult={goToPreviousResult}
31+
placeholder="Search (text or /regex/)"
32+
search={search}
33+
searchIndex={searchIndex}
34+
searchResultsCount={searchResults.length}
35+
searchText={searchText}
36+
/>
37+
);
38+
}

packages/react-devtools-shared/src/devtools/views/Components/Tree.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {BridgeContext, StoreContext, OptionsContext} from '../context';
2727
import Element from './Element';
2828
import InspectHostNodesToggle from './InspectHostNodesToggle';
2929
import OwnersStack from './OwnersStack';
30-
import SearchInput from './SearchInput';
30+
import ComponentSearchInput from './ComponentSearchInput';
3131
import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle';
3232
import SelectedTreeHighlight from './SelectedTreeHighlight';
3333
import TreeFocusedContext from './TreeFocusedContext';
@@ -343,7 +343,7 @@ export default function Tree(props: Props) {
343343
</Fragment>
344344
)}
345345
<Suspense fallback={<Loading />}>
346-
{ownerID !== null ? <OwnersStack /> : <SearchInput />}
346+
{ownerID !== null ? <OwnersStack /> : <ComponentSearchInput />}
347347
</Suspense>
348348
{showInlineWarningsAndErrors &&
349349
ownerID === null &&

packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -829,7 +829,7 @@ type Props = {|
829829
defaultSelectedElementIndex?: ?number,
830830
|};
831831

832-
// TODO Remove TreeContextController wrapper element once global ConsearchText.write API exists.
832+
// TODO Remove TreeContextController wrapper element once global Context.write API exists.
833833
function TreeContextController({
834834
children,
835835
defaultInspectedElementID,

packages/react-devtools-shared/src/devtools/views/Profiler/ClearProfilingDataButton.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,19 @@ export default function ClearProfilingDataButton() {
2020
const {didRecordCommits, isProfiling, selectedTabID} = useContext(
2121
ProfilerContext,
2222
);
23-
const {clearTimelineData, timelineData} = useContext(TimelineContext);
23+
const {file, setFile} = useContext(TimelineContext);
2424
const {profilerStore} = store;
2525

2626
let doesHaveData = false;
2727
if (selectedTabID === 'timeline') {
28-
doesHaveData = timelineData !== null;
28+
doesHaveData = file !== null;
2929
} else {
3030
doesHaveData = didRecordCommits;
3131
}
3232

3333
const clear = () => {
3434
if (selectedTabID === 'timeline') {
35-
clearTimelineData();
35+
setFile(null);
3636
} else {
3737
profilerStore.clear();
3838
}

packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,9 @@
115115
.Link {
116116
color: var(--color-button);
117117
}
118+
119+
.TimlineSearchInputContainer {
120+
flex: 1 1;
121+
display: flex;
122+
align-items: center;
123+
}

packages/react-devtools-shared/src/devtools/views/Profiler/Profiler.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views
2828
import {SettingsModalContextController} from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContext';
2929
import portaledContent from '../portaledContent';
3030
import {StoreContext} from '../context';
31+
import {TimelineContext} from 'react-devtools-timeline/src/TimelineContext';
3132

3233
import styles from './Profiler.css';
3334

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

47+
const {searchInputContainerRef} = useContext(TimelineContext);
48+
4649
const {supportsTimeline} = useContext(StoreContext);
4750

48-
let isLegacyProfilerSelected = false;
51+
const isLegacyProfilerSelected = selectedTabID !== 'timeline';
4952

5053
let view = null;
5154
if (didRecordCommits || selectedTabID === 'timeline') {
5255
switch (selectedTabID) {
5356
case 'flame-chart':
54-
isLegacyProfilerSelected = true;
5557
view = <CommitFlamegraph />;
5658
break;
5759
case 'ranked-chart':
58-
isLegacyProfilerSelected = true;
5960
view = <CommitRanked />;
6061
break;
6162
case 'timeline':
@@ -121,6 +122,12 @@ function Profiler(_: {||}) {
121122
/>
122123
<RootSelector />
123124
<div className={styles.Spacer} />
125+
{!isLegacyProfilerSelected && (
126+
<div
127+
ref={searchInputContainerRef}
128+
className={styles.TimlineSearchInputContainer}
129+
/>
130+
)}
124131
<SettingsModalContextToggle />
125132
{isLegacyProfilerSelected && didRecordCommits && (
126133
<Fragment>

packages/react-devtools-shared/src/devtools/views/Profiler/ProfilingImportExportButtons.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export default function ProfilingImportExportButtons() {
2929
const {isProfiling, profilingData, rootID, selectedTabID} = useContext(
3030
ProfilerContext,
3131
);
32-
const {importTimelineData} = useContext(TimelineContext);
32+
const {setFile} = useContext(TimelineContext);
3333
const store = useContext(StoreContext);
3434
const {profilerStore} = store;
3535

@@ -111,7 +111,8 @@ export default function ProfilingImportExportButtons() {
111111
const importTimelineDataWrapper = event => {
112112
const input = inputRef.current;
113113
if (input !== null && input.files.length > 0) {
114-
importTimelineData(input.files[0]);
114+
const file = input.files[0];
115+
setFile(file);
115116
}
116117
};
117118

packages/react-devtools-shared/src/devtools/views/Components/SearchInput.js renamed to packages/react-devtools-shared/src/devtools/views/SearchInput.js

Lines changed: 44 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,52 +8,56 @@
88
*/
99

1010
import * as React from 'react';
11-
import {useCallback, useContext, useEffect, useRef} from 'react';
12-
import {TreeDispatcherContext, TreeStateContext} from './TreeContext';
13-
import Button from '../Button';
14-
import ButtonIcon from '../ButtonIcon';
15-
import Icon from '../Icon';
11+
import {useEffect, useRef} from 'react';
12+
import Button from './Button';
13+
import ButtonIcon from './ButtonIcon';
14+
import Icon from './Icon';
1615

1716
import styles from './SearchInput.css';
1817

19-
type Props = {||};
20-
21-
export default function SearchInput(props: Props) {
22-
const {searchIndex, searchResults, searchText} = useContext(TreeStateContext);
23-
const dispatch = useContext(TreeDispatcherContext);
18+
type Props = {|
19+
goToNextResult: () => void,
20+
goToPreviousResult: () => void,
21+
placeholder: string,
22+
search: (text: string) => void,
23+
searchIndex: number,
24+
searchResultsCount: number,
25+
searchText: string,
26+
|};
2427

28+
export default function SearchInput({
29+
goToNextResult,
30+
goToPreviousResult,
31+
placeholder,
32+
search,
33+
searchIndex,
34+
searchResultsCount,
35+
searchText,
36+
}: Props) {
2537
const inputRef = useRef<HTMLInputElement | null>(null);
2638

27-
const handleTextChange = useCallback(
28-
({currentTarget}) =>
29-
dispatch({type: 'SET_SEARCH_TEXT', payload: currentTarget.value}),
30-
[dispatch],
31-
);
32-
const resetSearch = useCallback(
33-
() => dispatch({type: 'SET_SEARCH_TEXT', payload: ''}),
34-
[dispatch],
35-
);
39+
const resetSearch = () => search('');
3640

37-
const handleInputKeyPress = useCallback(
38-
({key, shiftKey}) => {
39-
if (key === 'Enter') {
40-
if (shiftKey) {
41-
dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'});
42-
} else {
43-
dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'});
44-
}
41+
const handleChange = ({currentTarget}) => {
42+
search(currentTarget.value);
43+
};
44+
const handleKeyPress = ({key, shiftKey}) => {
45+
if (key === 'Enter') {
46+
if (shiftKey) {
47+
goToPreviousResult();
48+
} else {
49+
goToNextResult();
4550
}
46-
},
47-
[dispatch],
48-
);
51+
}
52+
};
4953

5054
// Auto-focus search input
5155
useEffect(() => {
5256
if (inputRef.current === null) {
5357
return () => {};
5458
}
5559

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

73-
return () => ownerDocument.removeEventListener('keydown', handleWindowKey);
77+
return () => ownerDocument.removeEventListener('keydown', handleKeyDown);
7478
}, [inputRef]);
7579

7680
return (
7781
<div className={styles.SearchInput}>
7882
<Icon className={styles.InputIcon} type="search" />
7983
<input
8084
className={styles.Input}
81-
onChange={handleTextChange}
82-
onKeyPress={handleInputKeyPress}
83-
placeholder="Search (text or /regex/)"
85+
onChange={handleChange}
86+
onKeyPress={handleKeyPress}
87+
placeholder={placeholder}
8488
ref={inputRef}
8589
value={searchText}
8690
/>
8791
{!!searchText && (
8892
<React.Fragment>
8993
<span className={styles.IndexLabel}>
90-
{Math.min(searchIndex + 1, searchResults.length)} |{' '}
91-
{searchResults.length}
94+
{Math.min(searchIndex + 1, searchResultsCount)} |{' '}
95+
{searchResultsCount}
9296
</span>
9397
<div className={styles.LeftVRule} />
9498
<Button
9599
className={styles.IconButton}
96100
disabled={!searchText}
97-
onClick={() => dispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'})}
101+
onClick={goToPreviousResult}
98102
title={
99103
<React.Fragment>
100104
Scroll to previous search result (<kbd>Shift</kbd> +{' '}
@@ -106,7 +110,7 @@ export default function SearchInput(props: Props) {
106110
<Button
107111
className={styles.IconButton}
108112
disabled={!searchText}
109-
onClick={() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'})}
113+
onClick={goToNextResult}
110114
title={
111115
<React.Fragment>
112116
Scroll to next search result (<kbd>Enter</kbd>)

packages/react-devtools-timeline/src/CanvasPage.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import ContextMenuItem from 'react-devtools-shared/src/devtools/ContextMenu/Cont
6262
import useContextMenu from 'react-devtools-shared/src/devtools/ContextMenu/useContextMenu';
6363
import {getBatchRange} from './utils/getBatchRange';
6464
import {MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL} from './view-base/constants';
65+
import {TimelineSearchContext} from './TimelineSearchContext';
6566

6667
import styles from './CanvasPage.css';
6768

@@ -170,6 +171,32 @@ function AutoSizedCanvas({
170171
[],
171172
);
172173

174+
const {searchIndex, searchRegExp, searchResults} = useContext(
175+
TimelineSearchContext,
176+
);
177+
178+
useLayoutEffect(() => {
179+
viewState.updateSearchRegExpState(searchRegExp);
180+
181+
const componentMeasure =
182+
searchResults.length > 0 ? searchResults[searchIndex] : null;
183+
if (componentMeasure != null) {
184+
const scrollState = moveStateToRange({
185+
state: viewState.horizontalScrollState,
186+
rangeStart: componentMeasure.timestamp,
187+
rangeEnd: componentMeasure.timestamp + componentMeasure.duration,
188+
contentLength: data.duration,
189+
minContentLength: data.duration * MIN_ZOOM_LEVEL,
190+
maxContentLength: data.duration * MAX_ZOOM_LEVEL,
191+
containerLength: width,
192+
});
193+
194+
viewState.updateHorizontalScrollState(scrollState);
195+
}
196+
197+
surfaceRef.current.displayIfNeeded();
198+
}, [searchIndex, searchRegExp, searchResults, viewState]);
199+
173200
const surfaceRef = useRef(new Surface(resetHoveredEvent));
174201
const userTimingMarksViewRef = useRef(null);
175202
const nativeEventsViewRef = useRef(null);
@@ -334,6 +361,7 @@ function AutoSizedCanvas({
334361
surface,
335362
defaultFrame,
336363
data,
364+
viewState,
337365
);
338366
componentMeasuresViewRef.current = componentMeasuresView;
339367
componentMeasuresViewWrapper = createViewHelper(

0 commit comments

Comments
 (0)