Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Timeline search #22799

Merged
merged 3 commits into from Dec 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -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}
/>
);
}
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
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
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
Expand Up @@ -115,3 +115,9 @@
.Link {
color: var(--color-button);
}

.TimlineSearchInputContainer {
flex: 1 1;
display: flex;
align-items: center;
}
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);
bvaughn marked this conversation as resolved.
Show resolved Hide resolved

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
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
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);
}, [inputRef]);
return () => ownerDocument.removeEventListener('keydown', handleKeyDown);
}, []);

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
31 changes: 31 additions & 0 deletions packages/react-devtools-timeline/src/CanvasPage.js
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,35 @@ function AutoSizedCanvas({
[],
);

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

// This effect searches timeline data and scrolls to the next match wen search criteria change.
useLayoutEffect(() => {
bvaughn marked this conversation as resolved.
Show resolved Hide resolved
viewState.updateSearchRegExpState(searchRegExp);

const componentMeasureSearchResult =
searchResults.length > 0 ? searchResults[searchIndex] : null;
if (componentMeasureSearchResult != null) {
const scrollState = moveStateToRange({
state: viewState.horizontalScrollState,
rangeStart: componentMeasureSearchResult.timestamp,
rangeEnd:
componentMeasureSearchResult.timestamp +
componentMeasureSearchResult.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 +364,7 @@ function AutoSizedCanvas({
surface,
defaultFrame,
data,
viewState,
);
componentMeasuresViewRef.current = componentMeasuresView;
componentMeasuresViewWrapper = createViewHelper(
Expand Down