Skip to content

Commit f7b44c8

Browse files
committed
Add a menu to copy the Marker Table as text
1 parent b0614b5 commit f7b44c8

File tree

8 files changed

+359
-8
lines changed

8 files changed

+359
-8
lines changed

locales/en-US/app.ftl

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,16 @@ MarkerContextMenu--select-the-sender-thread =
468468
MarkerFiltersContextMenu--drop-samples-outside-of-markers-matching =
469469
Drop samples outside of markers matching “<strong>{ $filter }</strong>”
470470
471+
## MarkerCopyTableContextMenu
472+
## This is the menu when the copy icon is clicked in Marker Chart and Marker
473+
## Table panels.
474+
475+
MarkerCopyTableContextMenu--copy-table-as-plain =
476+
Copy marker table as plain text
477+
478+
MarkerCopyTableContextMenu--copy-table-as-markdown =
479+
Copy marker table as Markdown
480+
471481
## MarkerSettings
472482
## This is used in all panels related to markers.
473483

@@ -478,6 +488,9 @@ MarkerSettings--panel-search =
478488
MarkerSettings--marker-filters =
479489
.title = Marker Filters
480490
491+
MarkerSettings--copy-table =
492+
.title = Copy table as text
493+
481494
## MarkerSidebar
482495
## This is the sidebar component that is used in Marker Table panel.
483496

src/components/marker-table/index.tsx

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from '../../actions/profile-view';
2424
import { MarkerSettings } from '../shared/MarkerSettings';
2525
import { formatSeconds, formatTimestamp } from '../../utils/format-numbers';
26+
import copy from 'copy-to-clipboard';
2627

2728
import './index.css';
2829

@@ -48,6 +49,8 @@ type MarkerDisplayData = {
4849
details: string;
4950
};
5051

52+
function assertExhaustiveCheck(_param: never) {}
53+
5154
class MarkerTree {
5255
_getMarker: (param: MarkerIndex) => Marker;
5356
_markerIndexes: MarkerIndex[];
@@ -71,6 +74,89 @@ class MarkerTree {
7174
this._getMarkerLabel = getMarkerLabel;
7275
}
7376

77+
copyTable = (format: 'plain' | 'markdown') => {
78+
const lines = [];
79+
80+
const startLabel = 'Start';
81+
const durationLabel = 'Duration';
82+
const nameLabel = 'Name';
83+
const detailsLabel = 'Details';
84+
85+
const header = [startLabel, durationLabel, nameLabel, detailsLabel];
86+
87+
let maxStartLength = startLabel.length;
88+
let maxDurationLength = durationLabel.length;
89+
let maxNameLength = nameLabel.length;
90+
91+
for (const index of this.getRoots()) {
92+
const data = this.getDisplayData(index);
93+
const duration = data.duration ?? '';
94+
95+
maxStartLength = Math.max(data.start.length, maxStartLength);
96+
maxDurationLength = Math.max(duration.length, maxDurationLength);
97+
maxNameLength = Math.max(data.name.length, maxNameLength);
98+
99+
lines.push([
100+
data.start,
101+
// Use "u" instead, to make the table aligned with fixed-width text.
102+
duration.replace(/μ/g, 'u'),
103+
data.name,
104+
data.details,
105+
]);
106+
}
107+
108+
let text = '';
109+
switch (format) {
110+
case 'plain': {
111+
const formatter = ([start, duration, name, details]: string[]) => {
112+
const line = [
113+
start.padStart(maxStartLength, ' '),
114+
duration.padStart(maxDurationLength, ' '),
115+
name.padStart(maxNameLength, ' '),
116+
];
117+
if (details) {
118+
line.push(details);
119+
}
120+
return line.join(' ');
121+
};
122+
123+
text += formatter(header) + '\n' + lines.map(formatter).join('\n');
124+
break;
125+
}
126+
case 'markdown': {
127+
const formatter = ([start, duration, name, details]: string[]) => {
128+
const line = [
129+
start.padStart(maxStartLength, ' '),
130+
duration.padStart(maxDurationLength, ' '),
131+
name.padStart(maxNameLength, ' '),
132+
details,
133+
];
134+
return '| ' + line.join(' | ') + ' |';
135+
};
136+
const sep =
137+
'|' +
138+
[
139+
'-'.repeat(maxStartLength + 1) + ':',
140+
'-'.repeat(maxDurationLength + 1) + ':',
141+
'-'.repeat(maxNameLength + 1) + ':',
142+
'-'.repeat(9),
143+
].join('|') +
144+
'|';
145+
text =
146+
formatter(header) +
147+
'\n' +
148+
sep +
149+
'\n' +
150+
lines.map(formatter).join('\n');
151+
break;
152+
}
153+
default:
154+
assertExhaustiveCheck(format);
155+
}
156+
157+
copy(text);
158+
};
159+
74160
getRoots(): MarkerIndex[] {
75161
return this._markerIndexes;
76162
}
@@ -263,7 +349,7 @@ class MarkerTableImpl extends PureComponent<Props> {
263349
role="tabpanel"
264350
aria-labelledby="marker-table-tab-button"
265351
>
266-
<MarkerSettings />
352+
<MarkerSettings copyTable={tree.copyTable} />
267353
{markerIndexes.length === 0 ? (
268354
<MarkerTableEmptyReasons />
269355
) : (
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
import { PureComponent } from 'react';
5+
import { MenuItem } from '@firefox-devtools/react-contextmenu';
6+
import { Localized } from '@fluent/react';
7+
8+
import { ContextMenu } from './ContextMenu';
9+
import explicitConnect from 'firefox-profiler/utils/connect';
10+
11+
import type { ConnectedProps } from 'firefox-profiler/utils/connect';
12+
13+
type OwnProps = {
14+
readonly onShow: () => void;
15+
readonly onHide: () => void;
16+
readonly onCopy: (format: 'plain' | 'markdown') => void;
17+
};
18+
19+
type Props = ConnectedProps<OwnProps, {}, {}>;
20+
21+
class MarkerCopyTableContextMenuImpl extends PureComponent<Props> {
22+
copyAsPlain = () => {
23+
const { onCopy } = this.props;
24+
onCopy('plain');
25+
};
26+
27+
copyAsMarkdown = () => {
28+
const { onCopy } = this.props;
29+
onCopy('markdown');
30+
};
31+
32+
override render() {
33+
const { onShow, onHide } = this.props;
34+
return (
35+
<ContextMenu
36+
id="MarkerCopyTableContextMenu"
37+
className="markerCopyTableContextMenu"
38+
onShow={onShow}
39+
onHide={onHide}
40+
>
41+
<MenuItem onClick={this.copyAsPlain}>
42+
<Localized id="MarkerCopyTableContextMenu--copy-table-as-plain">
43+
Copy marker table as plain text
44+
</Localized>
45+
</MenuItem>
46+
<MenuItem onClick={this.copyAsMarkdown}>
47+
<Localized id="MarkerCopyTableContextMenu--copy-table-as-markdown">
48+
Copy marker table as Markdown
49+
</Localized>
50+
</MenuItem>
51+
</ContextMenu>
52+
);
53+
}
54+
}
55+
56+
export const MarkerCopyTableContextMenu = explicitConnect<OwnProps, {}, {}>({
57+
component: MarkerCopyTableContextMenuImpl,
58+
});

src/components/shared/MarkerSettings.css

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,30 @@
1313
white-space: nowrap;
1414
}
1515

16-
.filterMarkersButton {
16+
.filterMarkersButton,
17+
.copyTableButton {
1718
position: relative;
1819
width: 24px;
1920
height: 24px;
2021
flex: none;
2122
padding-right: 30px;
2223
margin: 0 4px;
23-
background-image: url(../../../res/img/svg/filter.svg);
2424
background-position: 4px center;
2525
background-repeat: no-repeat;
2626
}
2727

28+
.filterMarkersButton {
29+
background-image: url(../../../res/img/svg/filter.svg);
30+
}
31+
32+
.copyTableButton {
33+
margin-right: 16px;
34+
background-image: url(../../../res/img/svg/copy-dark.svg);
35+
}
36+
2837
/* This is the dropdown arrow on the right of the button. */
29-
.filterMarkersButton::after {
38+
.filterMarkersButton::after,
39+
.copyTableButton::after {
3040
position: absolute;
3141
top: 2px;
3242
right: 2px;

src/components/shared/MarkerSettings.tsx

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,17 @@ import { getProfileUsesMultipleStackTypes } from 'firefox-profiler/selectors/pro
1414
import { PanelSearch } from './PanelSearch';
1515
import { StackImplementationSetting } from 'firefox-profiler/components/shared/StackImplementationSetting';
1616
import { MarkerFiltersContextMenu } from './MarkerFiltersContextMenu';
17+
import { MarkerCopyTableContextMenu } from './MarkerCopyTableContextMenu';
1718

1819
import type { ConnectedProps } from 'firefox-profiler/utils/connect';
1920

2021
import 'firefox-profiler/components/shared/PanelSettingsList.css';
2122
import './MarkerSettings.css';
2223

24+
type OwnProps = {
25+
readonly copyTable?: (format: 'plain' | 'markdown') => void;
26+
};
27+
2328
type StateProps = {
2429
readonly searchString: string;
2530
readonly allowSwitchingStackType: boolean;
@@ -29,7 +34,7 @@ type DispatchProps = {
2934
readonly changeMarkersSearchString: typeof changeMarkersSearchString;
3035
};
3136

32-
type Props = ConnectedProps<{}, StateProps, DispatchProps>;
37+
type Props = ConnectedProps<OwnProps, StateProps, DispatchProps>;
3338

3439
type State = {
3540
readonly isMarkerFiltersMenuVisible: boolean;
@@ -39,12 +44,16 @@ type State = {
3944
// Otherwise, if we check this in onClick event, the state will always be
4045
// `false` since the library already hid it on mousedown.
4146
readonly isFilterMenuVisibleOnMouseDown: boolean;
47+
readonly isMarkerCopyTableMenuVisible: boolean;
48+
readonly isCopyTableMenuVisibleOnMouseDown: boolean;
4249
};
4350

4451
class MarkerSettingsImpl extends PureComponent<Props, State> {
4552
override state = {
4653
isMarkerFiltersMenuVisible: false,
4754
isFilterMenuVisibleOnMouseDown: false,
55+
isMarkerCopyTableMenuVisible: false,
56+
isCopyTableMenuVisibleOnMouseDown: false,
4857
};
4958

5059
_onSearch = (value: string) => {
@@ -72,6 +81,36 @@ class MarkerSettingsImpl extends PureComponent<Props, State> {
7281
});
7382
};
7483

84+
_onClickToggleCopyTableMenu = (event: React.MouseEvent<HTMLElement>) => {
85+
const { isCopyTableMenuVisibleOnMouseDown } = this.state;
86+
if (isCopyTableMenuVisibleOnMouseDown) {
87+
// Do nothing as we would like to hide the menu if the menu was already visible on mouse down.
88+
return;
89+
}
90+
91+
const rect = event.currentTarget.getBoundingClientRect();
92+
// FIXME: Currently we assume that the context menu is 250px wide, but ideally
93+
// we should get the real width. It's not so easy though, because the context
94+
// menu is not rendered yet.
95+
const isRightAligned = rect.right > window.innerWidth - 250;
96+
97+
showMenu({
98+
data: null,
99+
id: 'MarkerCopyTableContextMenu',
100+
position: { x: isRightAligned ? rect.right : rect.left, y: rect.bottom },
101+
target: event.target,
102+
});
103+
};
104+
105+
_onCopyTable = (format: 'plain' | 'markdown') => {
106+
const { copyTable } = this.props;
107+
if (!copyTable) {
108+
return;
109+
}
110+
111+
copyTable(format);
112+
};
113+
75114
_onShowFiltersContextMenu = () => {
76115
this.setState({ isMarkerFiltersMenuVisible: true });
77116
};
@@ -80,15 +119,30 @@ class MarkerSettingsImpl extends PureComponent<Props, State> {
80119
this.setState({ isMarkerFiltersMenuVisible: false });
81120
};
82121

122+
_onShowCopyTableContextMenu = () => {
123+
this.setState({ isMarkerCopyTableMenuVisible: true });
124+
};
125+
126+
_onHideCopyTableContextMenu = () => {
127+
this.setState({ isMarkerCopyTableMenuVisible: false });
128+
};
129+
83130
_onMouseDownToggleFilterButton = () => {
84131
this.setState((state) => ({
85132
isFilterMenuVisibleOnMouseDown: state.isMarkerFiltersMenuVisible,
86133
}));
87134
};
88135

136+
_onMouseDownToggleCopyTableMenu = () => {
137+
this.setState((state) => ({
138+
isCopyTableMenuVisibleOnMouseDown: state.isMarkerCopyTableMenuVisible,
139+
}));
140+
};
141+
89142
override render() {
90-
const { searchString, allowSwitchingStackType } = this.props;
91-
const { isMarkerFiltersMenuVisible } = this.state;
143+
const { searchString, allowSwitchingStackType, copyTable } = this.props;
144+
const { isMarkerFiltersMenuVisible, isMarkerCopyTableMenuVisible } =
145+
this.state;
92146

93147
return (
94148
<div className="markerSettings">
@@ -99,6 +153,24 @@ class MarkerSettingsImpl extends PureComponent<Props, State> {
99153
</li>
100154
) : null}
101155
</ul>
156+
{copyTable ? (
157+
<Localized id="MarkerSettings--copy-table" attrs={{ title: true }}>
158+
<button
159+
className={classNames(
160+
'copyTableButton',
161+
'photon-button',
162+
'photon-button-ghost',
163+
{
164+
'photon-button-ghost--checked': isMarkerCopyTableMenuVisible,
165+
}
166+
)}
167+
title="Copy table as text"
168+
type="button"
169+
onClick={this._onClickToggleCopyTableMenu}
170+
onMouseDown={this._onMouseDownToggleCopyTableMenu}
171+
/>
172+
</Localized>
173+
) : null}
102174
<Localized
103175
id="MarkerSettings--panel-search"
104176
attrs={{ label: true, title: true }}
@@ -132,12 +204,21 @@ class MarkerSettingsImpl extends PureComponent<Props, State> {
132204
onShow={this._onShowFiltersContextMenu}
133205
onHide={this._onHideFiltersContextMenu}
134206
/>
207+
<MarkerCopyTableContextMenu
208+
onShow={this._onShowCopyTableContextMenu}
209+
onHide={this._onHideCopyTableContextMenu}
210+
onCopy={this._onCopyTable}
211+
/>
135212
</div>
136213
);
137214
}
138215
}
139216

140-
export const MarkerSettings = explicitConnect<{}, StateProps, DispatchProps>({
217+
export const MarkerSettings = explicitConnect<
218+
OwnProps,
219+
StateProps,
220+
DispatchProps
221+
>({
141222
mapStateToProps: (state) => ({
142223
searchString: getMarkersSearchString(state),
143224
allowSwitchingStackType: getProfileUsesMultipleStackTypes(state),

0 commit comments

Comments
 (0)