Skip to content

Commit 1b8f24b

Browse files
committed
Persist disclosure box state in the URL.
1 parent 01f3dee commit 1b8f24b

8 files changed

Lines changed: 209 additions & 32 deletions

File tree

src/actions/profile-view.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1775,6 +1775,17 @@ export function changeFunctionListSort(sort: SingleColumnSortState[]): Action {
17751775
};
17761776
}
17771777

1778+
export function changeFunctionListSectionOpen(
1779+
section: 'descendants' | 'ancestors' | 'self',
1780+
isOpen: boolean
1781+
): Action {
1782+
return {
1783+
type: 'CHANGE_FUNCTION_LIST_SECTION_OPEN',
1784+
section,
1785+
isOpen,
1786+
};
1787+
}
1788+
17781789
export function changeNetworkSearchString(searchString: string): Action {
17791790
return {
17801791
type: 'CHANGE_NETWORK_SEARCH_STRING',

src/app-logic/url-handling.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import type {
4242
IndexIntoFrameTable,
4343
MarkerIndex,
4444
SelectedMarkersPerThread,
45+
FunctionListSectionsOpenState,
4546
} from 'firefox-profiler/types';
4647
import {
4748
decodeUintArrayFromUrlComponent,
@@ -187,6 +188,7 @@ type CallTreeQuery = BaseQuery & {
187188
hideIdleSamples: null | undefined;
188189
ctSummary: string;
189190
functionListSort?: string; // "total-desc~self-asc" — primary first
191+
funcListSections?: string; // "descendants,self" — comma-separated open sections
190192
};
191193

192194
type MarkersQuery = BaseQuery & {
@@ -235,6 +237,7 @@ type Query = BaseQuery & {
235237

236238
// Function list specific
237239
functionListSort?: string;
240+
funcListSections?: string;
238241

239242
// Network specific
240243
networkSearch?: string;
@@ -394,6 +397,9 @@ export function getQueryStringFromUrlState(urlState: UrlState): string {
394397
query.functionListSort = convertFunctionListSortToString(
395398
urlState.profileSpecific.functionListSort
396399
);
400+
query.funcListSections = convertFunctionListSectionsOpenToString(
401+
urlState.profileSpecific.functionListSectionsOpen
402+
);
397403
}
398404
break;
399405
}
@@ -652,6 +658,9 @@ export function stateFromLocation(
652658
functionListSort: convertFunctionListSortFromString(
653659
query.functionListSort
654660
),
661+
functionListSectionsOpen: convertFunctionListSectionsOpenFromString(
662+
query.funcListSections
663+
),
655664
},
656665
};
657666
}
@@ -751,6 +760,58 @@ function convertFunctionListSortFromString(
751760
return parsed.reverse();
752761
}
753762

763+
// FunctionList section disclosure-box open/closed state. The URL stores a
764+
// comma-separated list of the open sections; the param is omitted when the
765+
// state matches the default (only "descendants" open). The value "none" is
766+
// used as a sentinel for the all-closed case so the param is non-empty.
767+
const FUNCTION_LIST_SECTION_NAMES: ReadonlyArray<
768+
keyof FunctionListSectionsOpenState
769+
> = ['descendants', 'ancestors', 'self'];
770+
const FUNCTION_LIST_SECTIONS_OPEN_DEFAULT: FunctionListSectionsOpenState = {
771+
descendants: true,
772+
ancestors: false,
773+
self: false,
774+
};
775+
776+
function convertFunctionListSectionsOpenToString(
777+
state: FunctionListSectionsOpenState
778+
): string | undefined {
779+
const matchesDefault = FUNCTION_LIST_SECTION_NAMES.every(
780+
(name) => state[name] === FUNCTION_LIST_SECTIONS_OPEN_DEFAULT[name]
781+
);
782+
if (matchesDefault) {
783+
return undefined;
784+
}
785+
const open = FUNCTION_LIST_SECTION_NAMES.filter((name) => state[name]);
786+
return open.length === 0 ? 'none' : open.join(',');
787+
}
788+
789+
function convertFunctionListSectionsOpenFromString(
790+
raw: string | null | void
791+
): FunctionListSectionsOpenState {
792+
if (raw === undefined || raw === null) {
793+
return { ...FUNCTION_LIST_SECTIONS_OPEN_DEFAULT };
794+
}
795+
const result: FunctionListSectionsOpenState = {
796+
descendants: false,
797+
ancestors: false,
798+
self: false,
799+
};
800+
if (raw === 'none' || raw === '') {
801+
return result;
802+
}
803+
for (const part of raw.split(',')) {
804+
if (
805+
part === 'descendants' ||
806+
part === 'ancestors' ||
807+
part === 'self'
808+
) {
809+
result[part] = true;
810+
}
811+
}
812+
return result;
813+
}
814+
754815
function convertGlobalTrackOrderFromString(
755816
rawString: string | null | void
756817
): TrackIndex[] {

src/components/calltree/ProfileFunctionListView.tsx

Lines changed: 89 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5+
import * as React from 'react';
6+
7+
import explicitConnect from 'firefox-profiler/utils/connect';
58
import { FunctionList } from './FunctionList';
69
import { SelfWing } from './SelfWing';
710
import { UpperWing } from './UpperWing';
@@ -10,37 +13,93 @@ import { DisclosureBox } from 'firefox-profiler/components/shared/DisclosureBox'
1013
import { StackSettings } from 'firefox-profiler/components/shared/StackSettings';
1114
import { TransformNavigator } from 'firefox-profiler/components/shared/TransformNavigator';
1215
import { ResizableWithSplitter } from '../shared/ResizableWithSplitter';
16+
import { getFunctionListSectionsOpen } from 'firefox-profiler/selectors/url-state';
17+
import { changeFunctionListSectionOpen } from 'firefox-profiler/actions/profile-view';
18+
19+
import type { FunctionListSectionsOpenState } from 'firefox-profiler/types';
20+
import type { ConnectedProps } from 'firefox-profiler/utils/connect';
1321

1422
import './Butterfly.css';
1523

16-
export const ProfileFunctionListView = () => (
17-
<div
18-
className="treeAndSidebarWrapper"
19-
id="function-list-tab"
20-
role="tabpanel"
21-
aria-labelledby="function-list-tab-button"
22-
>
23-
<StackSettings hideInvertCallstack />
24-
<TransformNavigator />
25-
<div className="butterflyWrapper">
26-
<FunctionList />
27-
<ResizableWithSplitter
28-
className="butterflyWings"
29-
splitterPosition="start"
30-
controlledProperty="width"
31-
percent={true}
32-
initialSize="50%"
24+
type StateProps = {
25+
readonly sectionsOpen: FunctionListSectionsOpenState;
26+
};
27+
28+
type DispatchProps = {
29+
readonly changeFunctionListSectionOpen: typeof changeFunctionListSectionOpen;
30+
};
31+
32+
type Props = ConnectedProps<{}, StateProps, DispatchProps>;
33+
34+
class ProfileFunctionListViewImpl extends React.PureComponent<Props> {
35+
_onDescendantsToggle = (isOpen: boolean) => {
36+
this.props.changeFunctionListSectionOpen('descendants', isOpen);
37+
};
38+
_onAncestorsToggle = (isOpen: boolean) => {
39+
this.props.changeFunctionListSectionOpen('ancestors', isOpen);
40+
};
41+
_onSelfToggle = (isOpen: boolean) => {
42+
this.props.changeFunctionListSectionOpen('self', isOpen);
43+
};
44+
45+
override render() {
46+
const { sectionsOpen } = this.props;
47+
return (
48+
<div
49+
className="treeAndSidebarWrapper"
50+
id="function-list-tab"
51+
role="tabpanel"
52+
aria-labelledby="function-list-tab-button"
3353
>
34-
<DisclosureBox label="Descendants">
35-
<UpperWing />
36-
</DisclosureBox>
37-
<DisclosureBox label="Ancestors" initialOpen={false}>
38-
<LowerWing />
39-
</DisclosureBox>
40-
<DisclosureBox label="Self" initialOpen={false}>
41-
<SelfWing />
42-
</DisclosureBox>
43-
</ResizableWithSplitter>
44-
</div>
45-
</div>
46-
);
54+
<StackSettings hideInvertCallstack />
55+
<TransformNavigator />
56+
<div className="butterflyWrapper">
57+
<FunctionList />
58+
<ResizableWithSplitter
59+
className="butterflyWings"
60+
splitterPosition="start"
61+
controlledProperty="width"
62+
percent={true}
63+
initialSize="50%"
64+
>
65+
<DisclosureBox
66+
label="Descendants"
67+
isOpen={sectionsOpen.descendants}
68+
onToggle={this._onDescendantsToggle}
69+
>
70+
<UpperWing />
71+
</DisclosureBox>
72+
<DisclosureBox
73+
label="Ancestors"
74+
isOpen={sectionsOpen.ancestors}
75+
onToggle={this._onAncestorsToggle}
76+
>
77+
<LowerWing />
78+
</DisclosureBox>
79+
<DisclosureBox
80+
label="Self"
81+
isOpen={sectionsOpen.self}
82+
onToggle={this._onSelfToggle}
83+
>
84+
<SelfWing />
85+
</DisclosureBox>
86+
</ResizableWithSplitter>
87+
</div>
88+
</div>
89+
);
90+
}
91+
}
92+
93+
export const ProfileFunctionListView = explicitConnect<
94+
{},
95+
StateProps,
96+
DispatchProps
97+
>({
98+
mapStateToProps: (state) => ({
99+
sectionsOpen: getFunctionListSectionsOpen(state),
100+
}),
101+
mapDispatchToProps: {
102+
changeFunctionListSectionOpen,
103+
},
104+
component: ProfileFunctionListViewImpl,
105+
});

src/components/shared/DisclosureBox.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import './DisclosureBox.css';
99
type Props = {
1010
readonly label: string;
1111
readonly initialOpen?: boolean;
12+
readonly isOpen?: boolean;
13+
readonly onToggle?: (isOpen: boolean) => void;
1214
readonly children: React.ReactNode;
1315
};
1416

@@ -22,12 +24,20 @@ export class DisclosureBox extends React.PureComponent<Props, State> {
2224
};
2325

2426
_onToggle = () => {
27+
const { isOpen, onToggle } = this.props;
28+
if (isOpen !== undefined) {
29+
if (onToggle) {
30+
onToggle(!isOpen);
31+
}
32+
return;
33+
}
2534
this.setState((state) => ({ isOpen: !state.isOpen }));
2635
};
2736

2837
override render() {
29-
const { label, children } = this.props;
30-
const { isOpen } = this.state;
38+
const { label, children, isOpen: controlledOpen } = this.props;
39+
const isOpen =
40+
controlledOpen !== undefined ? controlledOpen : this.state.isOpen;
3141

3242
return (
3343
<div className={`disclosureBox ${isOpen ? 'open' : 'closed'}`}>

src/reducers/url-state.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
IsOpenPerPanelState,
2525
TabID,
2626
SelectedMarkersPerThread,
27+
FunctionListSectionsOpenState,
2728
} from 'firefox-profiler/types';
2829

2930
import type { TabSlug } from '../app-logic/tabs-handling';
@@ -218,6 +219,24 @@ const functionListSort: Reducer<SingleColumnSortState[]> = (
218219
}
219220
};
220221

222+
const FUNCTION_LIST_SECTIONS_OPEN_DEFAULT: FunctionListSectionsOpenState = {
223+
descendants: true,
224+
ancestors: false,
225+
self: false,
226+
};
227+
228+
const functionListSectionsOpen: Reducer<FunctionListSectionsOpenState> = (
229+
state = FUNCTION_LIST_SECTIONS_OPEN_DEFAULT,
230+
action
231+
) => {
232+
switch (action.type) {
233+
case 'CHANGE_FUNCTION_LIST_SECTION_OPEN':
234+
return { ...state, [action.section]: action.isOpen };
235+
default:
236+
return state;
237+
}
238+
};
239+
221240
const networkSearchString: Reducer<string> = (state = '', action) => {
222241
switch (action.type) {
223242
case 'CHANGE_NETWORK_SEARCH_STRING':
@@ -807,6 +826,7 @@ const profileSpecific = combineReducers({
807826
selectedMarkers,
808827
markerTableSort,
809828
functionListSort,
829+
functionListSectionsOpen,
810830
// The timeline tracks used to be hidden and sorted by thread indexes, rather than
811831
// track indexes. The only way to migrate this information to tracks-based data is to
812832
// first retrieve the profile, so they can't be upgraded by the normal url upgrading

src/selectors/url-state.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type {
3232
TabID,
3333
IndexIntoSourceTable,
3434
MarkerIndex,
35+
FunctionListSectionsOpenState,
3536
} from 'firefox-profiler/types';
3637

3738
import type { TabSlug } from '../app-logic/tabs-handling';
@@ -122,6 +123,9 @@ export const getMarkerTableSort: Selector<SingleColumnSortState[]> = (state) =>
122123
getProfileSpecificState(state).markerTableSort;
123124
export const getFunctionListSort: Selector<SingleColumnSortState[]> = (state) =>
124125
getProfileSpecificState(state).functionListSort;
126+
export const getFunctionListSectionsOpen: Selector<
127+
FunctionListSectionsOpenState
128+
> = (state) => getProfileSpecificState(state).functionListSectionsOpen;
125129
export const getNetworkSearchString: Selector<string> = (state) =>
126130
getProfileSpecificState(state).networkSearchString;
127131
export const getSelectedTab: Selector<TabSlug> = (state) =>

src/types/actions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,11 @@ type UrlStateAction =
565565
readonly type: 'CHANGE_FUNCTION_LIST_SORT';
566566
readonly sort: SingleColumnSortState[];
567567
}
568+
| {
569+
readonly type: 'CHANGE_FUNCTION_LIST_SECTION_OPEN';
570+
readonly section: 'descendants' | 'ancestors' | 'self';
571+
readonly isOpen: boolean;
572+
}
568573
| {
569574
readonly type: 'CHANGE_NETWORK_SEARCH_STRING';
570575
readonly searchString: string;

src/types/state.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,13 @@ export type ProfileSpecificUrlState = {
401401
selectedMarkers: SelectedMarkersPerThread;
402402
markerTableSort: SingleColumnSortState[];
403403
functionListSort: SingleColumnSortState[];
404+
functionListSectionsOpen: FunctionListSectionsOpenState;
405+
};
406+
407+
export type FunctionListSectionsOpenState = {
408+
descendants: boolean;
409+
ancestors: boolean;
410+
self: boolean;
404411
};
405412

406413
export type UrlState = {

0 commit comments

Comments
 (0)