diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index fc75c150b6..d2ea040ce2 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -635,6 +635,17 @@ TrackContextMenu--hide-other-screenshots-tracks = Hide other Screenshots tracks TrackContextMenu--hide-track = Hide “{ $trackName }” TrackContextMenu--show-all-tracks = Show all tracks +# This is used in the tracks context menu as a button to show all the tracks +# below it. +TrackContextMenu--show-all-tracks-below = Show all tracks below + +## TrackSearchField +## The component that is used for the search input in the track context menu. + +TrackSearchField--search-input = + .placeholder = Enter filter terms + .title = Only display tracks that match a certain text + ## TransformNavigator ## Navigator for the applied transforms in the Call Tree, Flame Graph, and Stack ## Chart components. diff --git a/src/actions/profile-view.js b/src/actions/profile-view.js index fbaae5883e..6e9eaf86e8 100644 --- a/src/actions/profile-view.js +++ b/src/actions/profile-view.js @@ -647,6 +647,28 @@ export function showAllTracks(): ThunkAction { }; } +/** + * This action makes the tracks that are provided visible. + */ +export function showProvidedTracks( + globalTracksToShow: Set, + localTracksByPidToShow: Map> +): ThunkAction { + return (dispatch) => { + sendAnalytics({ + hitType: 'event', + eventCategory: 'timeline', + eventAction: 'show provided tracks', + }); + + dispatch({ + type: 'SHOW_PROVIDED_TRACKS', + globalTracksToShow, + localTracksByPidToShow, + }); + }; +} + /** * This action shows a specific global track. */ diff --git a/src/components/shared/TrackSearchField.css b/src/components/shared/TrackSearchField.css new file mode 100644 index 0000000000..9f3565a06f --- /dev/null +++ b/src/components/shared/TrackSearchField.css @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +.trackSearchField { + position: relative; /* to properly position the button */ + display: inline-flex; + + /* If context menu has long items, we should make sure to take the 100% of the width. */ + width: 100%; + flex-flow: row nowrap; + align-items: center; +} + +.photon-input.trackSearchFieldInput { + /* the chain class selectors are used here + to override the styling for the photon-input class */ + width: calc(100% - 10px); + height: 25px; + padding: 0 18px 0 17px; /* right padding for the reset button, left padding for the search icon */ + border: 0.5px solid #aaa; + margin: 0 5px; +} + +.trackSearchFieldInput { + position: relative; + flex: 1; + margin: 0; + background: url(../../../res/img/svg/searchfield-icon.svg) 3px center + no-repeat white; + background-size: 11px 11px; +} + +.trackSearchFieldButton { + position: absolute; + + /* 5px is margin and the other 5px is the location inside the input */ + right: 10px; + overflow: hidden; + width: 11px; + height: 11px; + padding: 0; + border: 0; + background: url(../../../res/img/svg/searchfield-cancel.svg) top left + no-repeat; + background-size: contain; + color: transparent; + -moz-user-focus: ignore; + vertical-align: middle; +} + +.trackSearchFieldInput:invalid + .trackSearchFieldButton { + visibility: hidden; +} diff --git a/src/components/shared/TrackSearchField.js b/src/components/shared/TrackSearchField.js new file mode 100644 index 0000000000..e724469fa9 --- /dev/null +++ b/src/components/shared/TrackSearchField.js @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// @flow +import * as React from 'react'; +import classNames from 'classnames'; +import { Localized } from '@fluent/react'; + +import './TrackSearchField.css'; + +type Props = {| + +className: string, + +currentSearchString: string, + +onSearch: (string) => void, +|}; + +export class TrackSearchField extends React.PureComponent { + searchFieldInput: {| current: HTMLInputElement | null |} = React.createRef(); + _onSearchFieldChange = (e: SyntheticEvent) => { + this.props.onSearch(e.currentTarget.value); + }; + + focus = () => { + if (this.searchFieldInput.current) { + this.searchFieldInput.current.focus(); + } + }; + + _onFormSubmit(e: SyntheticEvent) { + e.preventDefault(); + } + + _onClearButtonClick = () => { + if (this.searchFieldInput.current) { + this.searchFieldInput.current.focus(); + } + + this.props.onSearch(''); + }; + + render() { + const { currentSearchString, className } = this.props; + return ( +
+ + + + +
+ ); + } +} diff --git a/src/components/timeline/TrackContextMenu.js b/src/components/timeline/TrackContextMenu.js index 666b2ad2c1..de87c42d43 100644 --- a/src/components/timeline/TrackContextMenu.js +++ b/src/components/timeline/TrackContextMenu.js @@ -18,6 +18,7 @@ import { isolateScreenshot, hideLocalTrack, showLocalTrack, + showProvidedTracks, } from 'firefox-profiler/actions/profile-view'; import explicitConnect from 'firefox-profiler/utils/connect'; import { ensureExists } from 'firefox-profiler/utils/flow'; @@ -36,6 +37,11 @@ import { getHiddenLocalTracksByPid, getLocalTrackOrderByPid, } from 'firefox-profiler/selectors/url-state'; +import { TrackSearchField } from 'firefox-profiler/components/shared/TrackSearchField'; +import { + getSearchFilteredGlobalTracks, + getSearchFilteredLocalTracksByPid, +} from 'firefox-profiler/profile-logic/tracks'; import classNames from 'classnames'; import type { @@ -75,16 +81,96 @@ type DispatchProps = {| +isolateLocalTrack: typeof isolateLocalTrack, +isolateProcessMainThread: typeof isolateProcessMainThread, +isolateScreenshot: typeof isolateScreenshot, + +showProvidedTracks: typeof showProvidedTracks, +|}; + +type TimelineTrackContextMenuProps = ConnectedProps< + {||}, + StateProps, + DispatchProps +>; + +type TimelineTrackContextMenuState = {| + searchFilter: string, |}; -type Props = ConnectedProps<{||}, StateProps, DispatchProps>; +class TimelineTrackContextMenuImpl extends PureComponent< + TimelineTrackContextMenuProps, + TimelineTrackContextMenuState +> { + state = { searchFilter: '' }; + _trackSearchFieldElem: {| current: TrackSearchField | null |} = + React.createRef(); -class TimelineTrackContextMenuImpl extends PureComponent { _showAllTracks = (): void => { const { showAllTracks } = this.props; showAllTracks(); }; + _showProvidedTracks = (): void => { + const { + showProvidedTracks, + globalTracks, + globalTrackNames, + localTracksByPid, + localTrackNamesByPid, + threads, + } = this.props; + const { searchFilter } = this.state; + const searchFilteredGlobalTracks = getSearchFilteredGlobalTracks( + globalTracks, + globalTrackNames, + threads, + searchFilter + ); + const searchFilteredLocalTracksByPid = getSearchFilteredLocalTracksByPid( + localTracksByPid, + localTrackNamesByPid, + threads, + searchFilter + ); + + if ( + searchFilteredGlobalTracks === null || + searchFilteredLocalTracksByPid === null + ) { + // This shouldn't happen! + return; + } + + // We need to check each global tracks and add their local tracks to the + // filter as well to make them visible. + const localTracksByPidToShow = new Map(searchFilteredLocalTracksByPid); + for (const globalTrackIndex of searchFilteredGlobalTracks) { + const globalTrack = globalTracks[globalTrackIndex]; + if (!globalTrack.pid) { + // There is no local track for this one, skip it. + continue; + } + + // Get all the local tracks and provided ones. + const localTracks = ensureExists( + localTracksByPid.get(globalTrack.pid), + 'Expected to find local tracks for the given pid' + ); + const localTracksToShow = localTracksByPidToShow.get(globalTrack.pid); + // Check if their lengths are the same. If not, we must add all the local + // track indexes. + if ( + localTracksToShow === undefined || + localTracks.length !== localTracksToShow.size + ) { + // If they don't match, automatically show all the local tracks. + localTracksByPidToShow.set( + globalTrack.pid, + new Set(localTracks.keys()) + ); + } + } + + showProvidedTracks(searchFilteredGlobalTracks, localTracksByPidToShow); + }; + _toggleGlobalTrackVisibility = ( _, data: { trackIndex: TrackIndex } @@ -206,10 +292,52 @@ class TimelineTrackContextMenuImpl extends PureComponent { isolateLocalTrack(pid, trackIndex); }; - renderGlobalTrack(trackIndex: TrackIndex) { + // Check if the global track has a local track that also matches the filter. + // We should still show the global tracks that have them. + _globalTrackHasSearchFilterMatchedChildren( + track: GlobalTrack, + searchFilteredLocalTracksByPid: Map> | null + ): boolean { + if (!track.pid || searchFilteredLocalTracksByPid === null) { + return false; + } + + const searchFilteredLocalTracks = searchFilteredLocalTracksByPid.get( + track.pid + ); + if (!searchFilteredLocalTracks) { + // This should not happen, but fail with a false if it does. + return false; + } + return searchFilteredLocalTracks.size !== 0; + } + + renderGlobalTrack( + trackIndex: TrackIndex, + searchFilteredGlobalTracks: Set | null, + searchFilteredLocalTracksByPid: Map> | null + ) { const { hiddenGlobalTracks, globalTrackNames, globalTracks } = this.props; const isHidden = hiddenGlobalTracks.has(trackIndex); const track = globalTracks[trackIndex]; + const hasSearchFilterMatchedChildren = + this._globalTrackHasSearchFilterMatchedChildren( + track, + searchFilteredLocalTracksByPid + ); + const isHiddenBySearch = + searchFilteredGlobalTracks && !searchFilteredGlobalTracks.has(trackIndex); + + if (isHiddenBySearch && !hasSearchFilterMatchedChildren) { + // This means that both search filter doesn't match this global track, and + // it doesn't have any local track that matches to the filter. In this + // case, don't show it. + return null; + } + + // If a global track is selected by search, we should show all of its children. + const skipSearchFilterInChildren = + searchFilteredGlobalTracks !== null && !isHiddenBySearch; let title = `${globalTrackNames[trackIndex]}`; if (track.type === 'process') { @@ -217,29 +345,44 @@ class TimelineTrackContextMenuImpl extends PureComponent { } return ( - - {globalTrackNames[trackIndex]} - - {track.type === 'process' && ( - ({track.pid}) - )} - + + + {globalTrackNames[trackIndex]} + + {track.type === 'process' && ( + ({track.pid}) + )} + + {track.type === 'process' + ? this.renderLocalTracks( + trackIndex, + track.pid, + skipSearchFilterInChildren, + searchFilteredLocalTracksByPid + ) + : null} + ); } - renderLocalTracks(globalTrackIndex: TrackIndex, pid: Pid) { + renderLocalTracks( + globalTrackIndex: TrackIndex, + pid: Pid, + skipSearchFilter: boolean, + searchFilteredLocalTracksByPid: Map> | null + ) { const { hiddenLocalTracksByPid, localTrackOrderByPid, @@ -253,6 +396,15 @@ class TimelineTrackContextMenuImpl extends PureComponent { const hiddenLocalTracks = hiddenLocalTracksByPid.get(pid); const localTrackNames = localTrackNamesByPid.get(pid); const localTracks = localTracksByPid.get(pid); + // If it's null, include everything without filtering. + let searchFilteredLocalTracks = null; + // skipSearchFilter will be true when the parent global track matches the filter. + // It means that we can include all the local tracks without checking. + if (searchFilteredLocalTracksByPid !== null && !skipSearchFilter) { + // If there is a search filter AND we can't skip the search filter, then + // get the filtered local tracks, so we can filter. + searchFilteredLocalTracks = searchFilteredLocalTracksByPid.get(pid); + } if ( localTrackOrder === undefined || @@ -269,6 +421,14 @@ class TimelineTrackContextMenuImpl extends PureComponent { const localTrackMenuItems = []; for (const trackIndex of localTrackOrder) { + if ( + searchFilteredLocalTracks && + !searchFilteredLocalTracks.has(trackIndex) + ) { + // Search filter doesn't match this track, skip it. + continue; + } + localTrackMenuItems.push( { ); } + renderShowProvidedTracks() { + const { rightClickedTrack } = this.props; + if (rightClickedTrack !== null) { + return null; + } + + return ( + + + + Show all tracks below + + +
+ + ); + } + + renderTrackSearchField() { + const { rightClickedTrack } = this.props; + const { searchFilter } = this.state; + if (rightClickedTrack !== null) { + // This option should only be visible in the top context menu and not when + // user right clicks. + return null; + } + + return ( + + +
+ + ); + } + + _onSearch = (value: string) => { + this.setState({ searchFilter: value }); + }; + + _onShow = () => { + const trackFieldElement = this._trackSearchFieldElem.current; + if ( + // We need to focus the track search filter. But we can't use autoFocus + // property because this context menu is already rendered and hidden during + // the load of the web page. + trackFieldElement + ) { + // Allow time for React contect menu to show itself first. + setTimeout(() => { + trackFieldElement.focus(); + }); + } + }; + + _onHide = () => { + this.setState({ searchFilter: '' }); + }; + render() { - const { globalTrackOrder, globalTracks, rightClickedTrack } = this.props; + const { + threads, + globalTrackOrder, + globalTracks, + globalTrackNames, + localTracksByPid, + localTrackNamesByPid, + rightClickedTrack, + } = this.props; + const { searchFilter } = this.state; const isolateProcessMainThread = this.renderIsolateProcessMainThread(); const isolateProcess = this.renderIsolateProcess(); const isolateLocalTrack = this.renderIsolateLocalTrack(); const isolateScreenshot = this.renderIsolateScreenshot(); const hideTrack = this.renderHideTrack(); - const showAllTracksMenu = this.renderShowAllTracks(); const separator = isolateProcessMainThread || isolateProcess || @@ -596,17 +827,34 @@ class TimelineTrackContextMenuImpl extends PureComponent { isolateScreenshot ? (
) : null; + const searchFilteredGlobalTracks = getSearchFilteredGlobalTracks( + globalTracks, + globalTrackNames, + threads, + searchFilter + ); + const searchFilteredLocalTracksByPid = getSearchFilteredLocalTracksByPid( + localTracksByPid, + localTrackNamesByPid, + threads, + searchFilter + ); return ( { // The menu items header items to isolate tracks may or may not be // visible depending on the current state. } - {showAllTracksMenu} + {this.renderTrackSearchField()} + {searchFilter + ? this.renderShowProvidedTracks() + : this.renderShowAllTracks()} {isolateProcessMainThread} {isolateProcess} {isolateLocalTrack} @@ -618,10 +866,11 @@ class TimelineTrackContextMenuImpl extends PureComponent { if (rightClickedTrack === null) { return (
- {this.renderGlobalTrack(globalTrackIndex)} - {globalTrack.type === 'process' - ? this.renderLocalTracks(globalTrackIndex, globalTrack.pid) - : null} + {this.renderGlobalTrack( + globalTrackIndex, + searchFilteredGlobalTracks, + searchFilteredLocalTracksByPid + )}
); } else if ( @@ -630,10 +879,11 @@ class TimelineTrackContextMenuImpl extends PureComponent { ) { return (
- {this.renderGlobalTrack(globalTrackIndex)} - {globalTrack.type === 'process' - ? this.renderLocalTracks(globalTrackIndex, globalTrack.pid) - : null} + {this.renderGlobalTrack( + globalTrackIndex, + searchFilteredGlobalTracks, + searchFilteredLocalTracksByPid + )}
); } else if ( @@ -643,10 +893,11 @@ class TimelineTrackContextMenuImpl extends PureComponent { if (rightClickedTrack.pid === globalTrack.pid) { return (
- {this.renderGlobalTrack(globalTrackIndex)} - {globalTrack.type === 'process' - ? this.renderLocalTracks(globalTrackIndex, globalTrack.pid) - : null} + {this.renderGlobalTrack( + globalTrackIndex, + searchFilteredGlobalTracks, + searchFilteredLocalTracksByPid + )}
); } @@ -686,6 +937,7 @@ export const TimelineTrackContextMenu = explicitConnect< isolateScreenshot, hideLocalTrack, showLocalTrack, + showProvidedTracks, }, component: TimelineTrackContextMenuImpl, }); diff --git a/src/profile-logic/tracks.js b/src/profile-logic/tracks.js index 0a3e980f05..49f0baff5c 100644 --- a/src/profile-logic/tracks.js +++ b/src/profile-logic/tracks.js @@ -20,6 +20,7 @@ import { isThreadWithNoPaint, isContentThreadWithNoPaint, } from './profile-data'; +import { splitSearchString, stringsToRegExp } from '../utils/string'; import { ensureExists, assertExhaustiveCheck } from '../utils/flow'; /** @@ -891,3 +892,196 @@ function _indexesAreValid(listLength: number, indexes: number[]) { .every((value, arrayIndex) => value === arrayIndex) ); } + +/** + * Get the search filter and return the search filtered global tracks. + * The search includes the fields like, track name, pid, tid, process type, + * process name, and eTLD+1. + */ +export function getSearchFilteredGlobalTracks( + tracks: GlobalTrack[], + globalTrackNames: string[], + threads: Thread[], + searchFilter: string +): Set | null { + if (!searchFilter) { + // Nothing is filtered, returning null. + return null; + } + const searchRegExp = stringsToRegExp(splitSearchString(searchFilter)); + if (!searchRegExp) { + // There is no search query, returning null. + return null; + } + + const searchFilteredGlobalTracks = new Set(); + for (let trackIndex = 0; trackIndex < tracks.length; trackIndex++) { + const globalTrack = tracks[trackIndex]; + + // Reset regexp for each iteration. Otherwise state from previous + // iterations can cause matches to fail if the search is global or + // sticky. + searchRegExp.lastIndex = 0; + + switch (globalTrack.type) { + case 'process': { + const { mainThreadIndex } = globalTrack; + // Check the pid of the global track first. + if (searchRegExp.test(globalTrack.pid.toString())) { + searchFilteredGlobalTracks.add(trackIndex); + continue; + } + + // Get the thread of the global track and check thread information. + if (mainThreadIndex !== null) { + const thread = threads[mainThreadIndex]; + + const threadName = globalTrackNames[trackIndex]; + if (searchRegExp.test(threadName)) { + searchFilteredGlobalTracks.add(trackIndex); + continue; + } + + const { tid } = thread; + if (tid && searchRegExp.test(tid.toString())) { + searchFilteredGlobalTracks.add(trackIndex); + continue; + } + + if (searchRegExp.test(thread.processType)) { + searchFilteredGlobalTracks.add(trackIndex); + continue; + } + + const { processName } = thread; + if (processName && searchRegExp.test(processName)) { + searchFilteredGlobalTracks.add(trackIndex); + continue; + } + + const etldPlus1 = thread['eTLD+1']; + if (etldPlus1 && searchRegExp.test(etldPlus1)) { + searchFilteredGlobalTracks.add(trackIndex); + continue; + } + } + + break; + } + case 'screenshots': + case 'visual-progress': + case 'perceptual-visual-progress': + case 'contentful-visual-progress': { + const { type } = globalTrack; + if (searchRegExp.test(type)) { + searchFilteredGlobalTracks.add(trackIndex); + continue; + } + break; + } + default: + throw assertExhaustiveCheck(globalTrack, 'Unhandled GlobalTrack type.'); + } + } + + return searchFilteredGlobalTracks; +} + +/** + * Get the search filter and return the search filtered local tracks by Pid. + * The search includes the fields like, track name, pid, tid, process type, + * process name, and eTLD+1. + */ +export function getSearchFilteredLocalTracksByPid( + localTracksByPid: Map, + localTrackNamesByPid: Map, + threads: Thread[], + searchFilter: string +): Map> | null { + if (!searchFilter) { + // Nothing is filtered, returning null. + return null; + } + const searchRegExp = stringsToRegExp(splitSearchString(searchFilter)); + if (!searchRegExp) { + // There is no search query, returning null. + return null; + } + + const searchFilteredLocalTracksByPid = new Map(); + for (const [pid, tracks] of localTracksByPid) { + const searchFilteredLocalTracks = new Set(); + const localTrackNames = localTrackNamesByPid.get(pid); + if (localTrackNames === undefined) { + throw new Error('Failed to get the local track names'); + } + + for (let trackIndex = 0; trackIndex < tracks.length; trackIndex++) { + const localTrack = tracks[trackIndex]; + // Reset regexp for each iteration. Otherwise state from previous + // iterations can cause matches to fail if the search is global or + // sticky. + searchRegExp.lastIndex = 0; + + if (searchRegExp.test(pid.toString())) { + searchFilteredLocalTracks.add(trackIndex); + continue; + } + + switch (localTrack.type) { + case 'thread': { + const { threadIndex } = localTrack; + // Get the thread of the local track and check thread information. + const thread = threads[threadIndex]; + + const threadName = localTrackNames[trackIndex]; + if (searchRegExp.test(threadName)) { + searchFilteredLocalTracks.add(trackIndex); + continue; + } + + const { tid } = thread; + if (tid && searchRegExp.test(tid.toString())) { + searchFilteredLocalTracks.add(trackIndex); + continue; + } + + if (searchRegExp.test(thread.processType)) { + searchFilteredLocalTracks.add(trackIndex); + continue; + } + + const { processName } = thread; + if (processName && searchRegExp.test(processName)) { + searchFilteredLocalTracks.add(trackIndex); + continue; + } + + const etldPlus1 = thread['eTLD+1']; + if (etldPlus1 && searchRegExp.test(etldPlus1)) { + searchFilteredLocalTracks.add(trackIndex); + continue; + } + break; + } + case 'network': + case 'memory': + case 'ipc': + case 'event-delay': { + const { type } = localTrack; + if (searchRegExp.test(type)) { + searchFilteredLocalTracks.add(trackIndex); + continue; + } + break; + } + default: + throw assertExhaustiveCheck(localTrack, 'Unhandled LocalTrack type.'); + } + } + + searchFilteredLocalTracksByPid.set(pid, searchFilteredLocalTracks); + } + + return searchFilteredLocalTracksByPid; +} diff --git a/src/reducers/app.js b/src/reducers/app.js index 14395f799c..74e1158b9f 100644 --- a/src/reducers/app.js +++ b/src/reducers/app.js @@ -127,6 +127,7 @@ const panelLayoutGeneration: Reducer = (state = 0, action) => { // Timeline: (fallthrough) case 'HIDE_GLOBAL_TRACK': case 'SHOW_ALL_TRACKS': + case 'SHOW_PROVIDED_TRACKS': case 'SHOW_GLOBAL_TRACK': case 'ISOLATE_PROCESS': case 'ISOLATE_PROCESS_MAIN_THREAD': diff --git a/src/reducers/url-state.js b/src/reducers/url-state.js index 54e926b370..273a261876 100644 --- a/src/reducers/url-state.js +++ b/src/reducers/url-state.js @@ -347,6 +347,15 @@ const hiddenGlobalTracks: Reducer> = ( } case 'SHOW_ALL_TRACKS': return new Set(); + case 'SHOW_PROVIDED_TRACKS': { + const hiddenGlobalTracks = new Set(state); + // Remove all the provided global tracks from the hidden global tracks. + action.globalTracksToShow.forEach( + Set.prototype.delete, + hiddenGlobalTracks + ); + return hiddenGlobalTracks; + } case 'SHOW_GLOBAL_TRACK': { const hiddenGlobalTracks = new Set(state); hiddenGlobalTracks.delete(action.trackIndex); @@ -382,6 +391,23 @@ const hiddenLocalTracksByPid: Reducer>> = ( } return hiddenLocalTracksByPid; } + case 'SHOW_PROVIDED_TRACKS': { + const hiddenLocalTracksByPid = new Map(); + // Go through the local tracks and make the provided ones visible. + for (const [pid, hiddenLocalTracks] of state.entries()) { + const newHiddenLocalTracks = new Set(hiddenLocalTracks); + // Remove all provided local tracks. + const localTracks = action.localTracksByPidToShow.get(pid); + + if (!localTracks) { + throw new Error('Failed to find the local tracks'); + } + + localTracks.forEach(Set.prototype.delete, newHiddenLocalTracks); + hiddenLocalTracksByPid.set(pid, newHiddenLocalTracks); + } + return hiddenLocalTracksByPid; + } case 'SHOW_LOCAL_TRACK': { const hiddenLocalTracksByPid = new Map(state); const hiddenLocalTracks = new Set(hiddenLocalTracksByPid.get(action.pid)); diff --git a/src/selectors/url-state.js b/src/selectors/url-state.js index 632c5b6182..5cbbaebdd6 100644 --- a/src/selectors/url-state.js +++ b/src/selectors/url-state.js @@ -3,7 +3,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ // @flow -import escapeStringRegexp from 'escape-string-regexp'; import { createSelector } from 'reselect'; import { ensureExists, getFirstItemFromSet } from '../utils/flow'; import { urlFromState } from '../app-logic/url-handling'; @@ -11,6 +10,7 @@ import * as CommittedRanges from '../profile-logic/committed-ranges'; import { getThreadsKey } from '../profile-logic/profile-data'; import { getProfileNameFromZipPath } from 'firefox-profiler/profile-logic/zip-files'; import { SYMBOL_SERVER_URL } from '../app-logic/constants'; +import { splitSearchString, stringsToRegExp } from '../utils/string'; import type { ThreadIndex, @@ -160,37 +160,6 @@ export const getLocalTrackOrder: DangerousSelectorWithArguments< 'Unable to get the track order from the given pid' ); -/** - * Divide a search string into several parts by splitting on comma. - */ -const splitSearchString = (searchString: string): string[] | null => { - if (!searchString) { - return null; - } - const result = searchString - .split(',') - .map((part) => part.trim()) - .filter((part) => part); - - if (result.length) { - return result; - } - - return null; -}; - -/** - * Concatenate an array of strings into a RegExp that matches on all - * the strings. - */ -const stringsToRegExp = (strings: string[] | null): RegExp | null => { - if (!strings || !strings.length) { - return null; - } - const regexpStr = strings.map(escapeStringRegexp).join('|'); - return new RegExp(regexpStr, 'gi'); -}; - /** * Search strings filter a thread to only samples that match the strings. */ diff --git a/src/test/components/TrackContextMenu.test.js b/src/test/components/TrackContextMenu.test.js index f542472e94..341e3f03ae 100644 --- a/src/test/components/TrackContextMenu.test.js +++ b/src/test/components/TrackContextMenu.test.js @@ -6,6 +6,7 @@ import * as React from 'react'; import { Provider } from 'react-redux'; +import { fireEvent } from '@testing-library/react'; import { render, screen } from 'firefox-profiler/test/fixtures/testing-library'; import { ensureExists } from '../../utils/flow'; @@ -32,6 +33,10 @@ import { storeWithProfile } from '../fixtures/stores'; import { fireFullClick } from '../fixtures/utils'; describe('timeline/TrackContextMenu', function () { + beforeEach(() => { + jest.useFakeTimers(); + }); + /** * getProfileWithNiceTracks() looks like: [ * 'show [thread GeckoMain process]', @@ -50,12 +55,21 @@ describe('timeline/TrackContextMenu', function () { ); + const changeSearchFilter = (searchText: string) => { + fireEvent.change(screen.getByPlaceholderText(/Enter filter terms/), { + target: { value: searchText }, + }); + + jest.runAllTimers(); + }; + return { ...renderResult, dispatch, getState, profile, store, + changeSearchFilter, }; } @@ -110,6 +124,155 @@ describe('timeline/TrackContextMenu', function () { }); }); + describe('show all tracks below', function () { + function setupAllTracks() { + const results = setup(); + const selectAllTracksBelowItem = () => + screen.getByText('Show all tracks below'); + + const hideAllTracks = () => { + // To hide the tracks before testing 'Show all tracks' + fireFullClick(screen.getByText('GeckoMain')); + fireFullClick(screen.getByText('DOM Worker')); + fireFullClick(screen.getByText('Style')); + }; + + return { + ...results, + selectAllTracksBelowItem, + hideAllTracks, + }; + } + + it('shows a single track', () => { + const { + getState, + selectAllTracksBelowItem, + hideAllTracks, + changeSearchFilter, + } = setupAllTracks(); + // Hide all tracks to test the behavior. + hideAllTracks(); + expect(getHumanReadableTracks(getState())).toEqual([ + // Check if the tracks have been hidden. + 'hide [thread GeckoMain process]', + // There must be at least one visible track. + 'show [thread GeckoMain tab] SELECTED', + ' - hide [thread DOM Worker]', + ' - hide [thread Style]', + ]); + + // Search something to filter the tracks. + changeSearchFilter('GeckoMain'); + // Click the button. + fireFullClick(selectAllTracksBelowItem()); + + // GeckoMain should be visible now. + expect(getHumanReadableTracks(getState())).toEqual([ + 'show [thread GeckoMain process]', + 'show [thread GeckoMain tab] SELECTED', + ' - hide [thread DOM Worker]', + ' - hide [thread Style]', + ]); + }); + + it('shows children of a global track', () => { + const { + getState, + selectAllTracksBelowItem, + hideAllTracks, + changeSearchFilter, + } = setupAllTracks(); + // Hide all tracks to test the behavior. + hideAllTracks(); + expect(getHumanReadableTracks(getState())).toEqual([ + // Check if the tracks have been hidden. + 'hide [thread GeckoMain process]', + // There must be at least one visible track. + 'show [thread GeckoMain tab] SELECTED', + ' - hide [thread DOM Worker]', + ' - hide [thread Style]', + ]); + + // Search something to filter the tracks. + changeSearchFilter('Content Process'); + // Click the button. + fireFullClick(selectAllTracksBelowItem()); + + // Children of Content Process should be visible now. + expect(getHumanReadableTracks(getState())).toEqual([ + 'hide [thread GeckoMain process]', + 'show [thread GeckoMain tab] SELECTED', + ' - show [thread DOM Worker]', + ' - show [thread Style]', + ]); + }); + + it('shows a local track', () => { + const { + getState, + selectAllTracksBelowItem, + hideAllTracks, + changeSearchFilter, + } = setupAllTracks(); + // Hide all tracks to test the behavior. + hideAllTracks(); + expect(getHumanReadableTracks(getState())).toEqual([ + // Check if the tracks have been hidden. + 'hide [thread GeckoMain process]', + // There must be at least one visible track. + 'show [thread GeckoMain tab] SELECTED', + ' - hide [thread DOM Worker]', + ' - hide [thread Style]', + ]); + + // Search something to filter the tracks. + changeSearchFilter('DOM Worker'); + // Click the button. + fireFullClick(selectAllTracksBelowItem()); + + // DOM Worker track should be visible now. + expect(getHumanReadableTracks(getState())).toEqual([ + 'hide [thread GeckoMain process]', + 'show [thread GeckoMain tab] SELECTED', + ' - show [thread DOM Worker]', + ' - hide [thread Style]', + ]); + }); + + it('does not show anything if the list is empty', () => { + const { + getState, + selectAllTracksBelowItem, + hideAllTracks, + changeSearchFilter, + } = setupAllTracks(); + // Hide all tracks to test the behavior. + hideAllTracks(); + expect(getHumanReadableTracks(getState())).toEqual([ + // Check if the tracks have been hidden. + 'hide [thread GeckoMain process]', + // There must be at least one visible track. + 'show [thread GeckoMain tab] SELECTED', + ' - hide [thread DOM Worker]', + ' - hide [thread Style]', + ]); + + // Search something to filter the tracks. This time it's something random. + changeSearchFilter('this should not be in the tracks list'); + // Click the button. + fireFullClick(selectAllTracksBelowItem()); + + // No new track should be visible. + expect(getHumanReadableTracks(getState())).toEqual([ + 'hide [thread GeckoMain process]', + 'show [thread GeckoMain tab] SELECTED', + ' - hide [thread DOM Worker]', + ' - hide [thread Style]', + ]); + }); + }); + describe('selected global track', function () { function setupGlobalTrack(profile, trackIndex = 1) { const results = setup(profile); @@ -437,4 +600,92 @@ describe('timeline/TrackContextMenu', function () { ]); }); }); + + describe('track search', function () { + it('can filter a single global track', () => { + const { changeSearchFilter } = setup(); + const searchText = 'GeckoMain'; + + // Check if all the tracks are visible at first. + expect(screen.getByText('GeckoMain')).toBeInTheDocument(); + expect(screen.getByText('Content Process')).toBeInTheDocument(); + expect(screen.getByText('Style')).toBeInTheDocument(); + + changeSearchFilter(searchText); + + jest.runAllTimers(); + + // Check if only the GeckoMain is in the document and not the others. + expect(screen.getByText('GeckoMain')).toBeInTheDocument(); + expect(screen.queryByText('Content Process')).not.toBeInTheDocument(); + expect(screen.queryByText('Style')).not.toBeInTheDocument(); + }); + + it('can filter a global track with its local track', () => { + const { changeSearchFilter } = setup(); + const searchText = 'Content Process'; + + // Check if all the tracks are visible at first. + expect(screen.getByText('GeckoMain')).toBeInTheDocument(); + expect(screen.getByText('Content Process')).toBeInTheDocument(); + expect(screen.getByText('Style')).toBeInTheDocument(); + + changeSearchFilter(searchText); + + jest.runAllTimers(); + + // Check if only Content Process and its children are in the document. + expect(screen.queryByText('GeckoMain')).not.toBeInTheDocument(); + expect(screen.getByText('Content Process')).toBeInTheDocument(); + expect(screen.getByText('Style')).toBeInTheDocument(); + }); + + it('can filter a local track with its global track', () => { + const { changeSearchFilter } = setup(); + const searchText = 'Style'; + + // Check if all the tracks are visible at first. + expect(screen.getByText('GeckoMain')).toBeInTheDocument(); + expect(screen.getByText('Content Process')).toBeInTheDocument(); + expect(screen.getByText('Style')).toBeInTheDocument(); + + changeSearchFilter(searchText); + + jest.runAllTimers(); + + // Check if only Content Process and its children are in the document. + expect(screen.queryByText('GeckoMain')).not.toBeInTheDocument(); + expect(screen.getByText('Content Process')).toBeInTheDocument(); + expect(screen.getByText('Style')).toBeInTheDocument(); + }); + + it('can filter a track with pid or processType', () => { + const { changeSearchFilter } = setup(); + let searchText = '111'; // pid of GeckoMain + + // Check if all the tracks are visible at first. + expect(screen.getByText('GeckoMain')).toBeInTheDocument(); + expect(screen.getByText('Content Process')).toBeInTheDocument(); + expect(screen.getByText('Style')).toBeInTheDocument(); + + changeSearchFilter(searchText); + + jest.runAllTimers(); + + // Check if only GeckoMain is in the document. + expect(screen.getByText('GeckoMain')).toBeInTheDocument(); + expect(screen.queryByText('Content Process')).not.toBeInTheDocument(); + expect(screen.queryByText('Style')).not.toBeInTheDocument(); + + searchText = 'tab'; // processType of Content Process + changeSearchFilter(searchText); + + jest.runAllTimers(); + + // Check if Content Process and its children are in the document. + expect(screen.queryByText('GeckoMain')).not.toBeInTheDocument(); + expect(screen.getByText('Content Process')).toBeInTheDocument(); + expect(screen.getByText('Style')).toBeInTheDocument(); + }); + }); }); diff --git a/src/types/actions.js b/src/types/actions.js index b882b277e3..2bb2006fcd 100644 --- a/src/types/actions.js +++ b/src/types/actions.js @@ -227,6 +227,11 @@ type ProfileAction = | {| +type: 'SHOW_ALL_TRACKS', |} + | {| + +type: 'SHOW_PROVIDED_TRACKS', + +globalTracksToShow: Set, + +localTracksByPidToShow: Map>, + |} | {| +type: 'SHOW_GLOBAL_TRACK', +trackIndex: TrackIndex, diff --git a/src/utils/string.js b/src/utils/string.js index 41a4e8bdf6..0302c961d3 100644 --- a/src/utils/string.js +++ b/src/utils/string.js @@ -4,6 +4,8 @@ // @flow +import escapeStringRegexp from 'escape-string-regexp'; + // Initializing this RegExp outside of removeURLs because that function is in a // hot path during sanitization and it's good to avoid the initialization of the // RegExp which is costly. @@ -75,3 +77,34 @@ export function removeFilePath( return redactedText + pathSeparator + filePath.slice(lastSeparatorIndex + 1); } + +/** + * Divide a search string into several parts by splitting on comma. + */ +export const splitSearchString = (searchString: string): string[] | null => { + if (!searchString) { + return null; + } + const result = searchString + .split(',') + .map((part) => part.trim()) + .filter((part) => part); + + if (result.length) { + return result; + } + + return null; +}; + +/** + * Concatenate an array of strings into a RegExp that matches on all + * the strings. + */ +export const stringsToRegExp = (strings: string[] | null): RegExp | null => { + if (!strings || !strings.length) { + return null; + } + const regexpStr = strings.map(escapeStringRegexp).join('|'); + return new RegExp(regexpStr, 'gi'); +};