From 07d15acaef5af8f0235fe8b6a6f5d57e6d68665c Mon Sep 17 00:00:00 2001 From: Tooru Fujisawa Date: Thu, 25 Dec 2025 17:26:37 +0900 Subject: [PATCH] Add a menu to copy the Marker Table as text --- locales/en-US/app.ftl | 13 ++ src/components/marker-table/index.tsx | 101 ++++++++++++++- .../shared/MarkerCopyTableContextMenu.tsx | 58 +++++++++ src/components/shared/MarkerSettings.css | 36 +++++- src/components/shared/MarkerSettings.tsx | 115 +++++++++++++++++- src/test/components/MarkerTable.test.tsx | 48 ++++++++ .../__snapshots__/MarkerChart.test.tsx.snap | 25 ++++ .../__snapshots__/MarkerTable.test.tsx.snap | 30 +++++ 8 files changed, 418 insertions(+), 8 deletions(-) create mode 100644 src/components/shared/MarkerCopyTableContextMenu.tsx diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 9eb89ea769..93cf29e01d 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -468,6 +468,16 @@ MarkerContextMenu--select-the-sender-thread = MarkerFiltersContextMenu--drop-samples-outside-of-markers-matching = Drop samples outside of markers matching “{ $filter }” +## MarkerCopyTableContextMenu +## This is the menu when the copy icon is clicked in Marker Chart and Marker +## Table panels. + +MarkerCopyTableContextMenu--copy-table-as-plain = + Copy marker table as plain text + +MarkerCopyTableContextMenu--copy-table-as-markdown = + Copy marker table as Markdown + ## MarkerSettings ## This is used in all panels related to markers. @@ -478,6 +488,9 @@ MarkerSettings--panel-search = MarkerSettings--marker-filters = .title = Marker Filters +MarkerSettings--copy-table = + .title = Copy table as text + ## MarkerSidebar ## This is the sidebar component that is used in Marker Table panel. diff --git a/src/components/marker-table/index.tsx b/src/components/marker-table/index.tsx index 57728e3b64..d5f2d567c2 100644 --- a/src/components/marker-table/index.tsx +++ b/src/components/marker-table/index.tsx @@ -23,6 +23,7 @@ import { } from '../../actions/profile-view'; import { MarkerSettings } from '../shared/MarkerSettings'; import { formatSeconds, formatTimestamp } from '../../utils/format-numbers'; +import copy from 'copy-to-clipboard'; import './index.css'; @@ -48,6 +49,8 @@ type MarkerDisplayData = { details: string; }; +function assertExhaustiveCheck(_param: never) {} + class MarkerTree { _getMarker: (param: MarkerIndex) => Marker; _markerIndexes: MarkerIndex[]; @@ -71,6 +74,102 @@ class MarkerTree { this._getMarkerLabel = getMarkerLabel; } + copyTable = ( + format: 'plain' | 'markdown', + onWarning: (message: string) => void + ) => { + const lines = []; + + const startLabel = 'Start'; + const durationLabel = 'Duration'; + const nameLabel = 'Name'; + const detailsLabel = 'Details'; + + const header = [startLabel, durationLabel, nameLabel, detailsLabel]; + + let maxStartLength = startLabel.length; + let maxDurationLength = durationLabel.length; + let maxNameLength = nameLabel.length; + + const MAX_COPY_ROWS = 10000; + + let roots = this.getRoots(); + if (roots.length > MAX_COPY_ROWS) { + onWarning( + `The number of rows hits the limit: ${roots.length} > ${MAX_COPY_ROWS}` + ); + roots = roots.slice(0, MAX_COPY_ROWS); + } + + for (const index of roots) { + const data = this.getDisplayData(index); + const duration = data.duration ?? ''; + + maxStartLength = Math.max(data.start.length, maxStartLength); + maxDurationLength = Math.max(duration.length, maxDurationLength); + maxNameLength = Math.max(data.name.length, maxNameLength); + + lines.push([ + data.start, + // Use "u" instead, to make the table aligned with fixed-width text. + duration.replace(/μ/g, 'u'), + data.name, + data.details, + ]); + } + + let text = ''; + switch (format) { + case 'plain': { + const formatter = ([start, duration, name, details]: string[]) => { + const line = [ + start.padStart(maxStartLength, ' '), + duration.padStart(maxDurationLength, ' '), + name.padStart(maxNameLength, ' '), + ]; + if (details) { + line.push(details); + } + return line.join(' '); + }; + + text += formatter(header) + '\n' + lines.map(formatter).join('\n'); + break; + } + case 'markdown': { + const formatter = ([start, duration, name, details]: string[]) => { + const line = [ + start.padStart(maxStartLength, ' '), + duration.padStart(maxDurationLength, ' '), + name.padStart(maxNameLength, ' '), + details, + ]; + return '| ' + line.join(' | ') + ' |'; + }; + const sep = + '|' + + [ + '-'.repeat(maxStartLength + 1) + ':', + '-'.repeat(maxDurationLength + 1) + ':', + '-'.repeat(maxNameLength + 1) + ':', + '-'.repeat(9), + ].join('|') + + '|'; + text = + formatter(header) + + '\n' + + sep + + '\n' + + lines.map(formatter).join('\n'); + break; + } + default: + assertExhaustiveCheck(format); + } + + copy(text); + }; + getRoots(): MarkerIndex[] { return this._markerIndexes; } @@ -263,7 +362,7 @@ class MarkerTableImpl extends PureComponent { role="tabpanel" aria-labelledby="marker-table-tab-button" > - + {markerIndexes.length === 0 ? ( ) : ( diff --git a/src/components/shared/MarkerCopyTableContextMenu.tsx b/src/components/shared/MarkerCopyTableContextMenu.tsx new file mode 100644 index 0000000000..b3049ecd4b --- /dev/null +++ b/src/components/shared/MarkerCopyTableContextMenu.tsx @@ -0,0 +1,58 @@ +/* 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/. */ +import { PureComponent } from 'react'; +import { MenuItem } from '@firefox-devtools/react-contextmenu'; +import { Localized } from '@fluent/react'; + +import { ContextMenu } from './ContextMenu'; +import explicitConnect from 'firefox-profiler/utils/connect'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +type OwnProps = { + readonly onShow: () => void; + readonly onHide: () => void; + readonly onCopy: (format: 'plain' | 'markdown') => void; +}; + +type Props = ConnectedProps; + +class MarkerCopyTableContextMenuImpl extends PureComponent { + copyAsPlain = () => { + const { onCopy } = this.props; + onCopy('plain'); + }; + + copyAsMarkdown = () => { + const { onCopy } = this.props; + onCopy('markdown'); + }; + + override render() { + const { onShow, onHide } = this.props; + return ( + + + + Copy marker table as plain text + + + + + Copy marker table as Markdown + + + + ); + } +} + +export const MarkerCopyTableContextMenu = explicitConnect({ + component: MarkerCopyTableContextMenuImpl, +}); diff --git a/src/components/shared/MarkerSettings.css b/src/components/shared/MarkerSettings.css index 84399301b8..dfdc00759a 100644 --- a/src/components/shared/MarkerSettings.css +++ b/src/components/shared/MarkerSettings.css @@ -13,20 +13,30 @@ white-space: nowrap; } -.filterMarkersButton { +.filterMarkersButton, +.copyTableButton { position: relative; width: 24px; height: 24px; flex: none; padding-right: 30px; margin: 0 4px; - background-image: url(../../../res/img/svg/filter.svg); background-position: 4px center; background-repeat: no-repeat; } +.filterMarkersButton { + background-image: url(../../../res/img/svg/filter.svg); +} + +.copyTableButton { + margin-right: 16px; + background-image: url(../../../res/img/svg/copy-dark.svg); +} + /* This is the dropdown arrow on the right of the button. */ -.filterMarkersButton::after { +.filterMarkersButton::after, +.copyTableButton::after { position: absolute; top: 2px; right: 2px; @@ -40,3 +50,23 @@ color: var(--grey-90); content: ''; } + +.copyTableButtonWarningWrapper { + /* Position */ + position: fixed; + z-index: 4; + top: 0; + right: 0; + left: 0; + + /* Box */ + display: flex; + + /* Other */ + pointer-events: none; +} + +.copyTableButtonWarning { + margin: 0 auto; + pointer-events: auto; +} diff --git a/src/components/shared/MarkerSettings.tsx b/src/components/shared/MarkerSettings.tsx index d7f8383e2e..25b72a5cb3 100644 --- a/src/components/shared/MarkerSettings.tsx +++ b/src/components/shared/MarkerSettings.tsx @@ -14,12 +14,17 @@ import { getProfileUsesMultipleStackTypes } from 'firefox-profiler/selectors/pro import { PanelSearch } from './PanelSearch'; import { StackImplementationSetting } from 'firefox-profiler/components/shared/StackImplementationSetting'; import { MarkerFiltersContextMenu } from './MarkerFiltersContextMenu'; +import { MarkerCopyTableContextMenu } from './MarkerCopyTableContextMenu'; import type { ConnectedProps } from 'firefox-profiler/utils/connect'; import 'firefox-profiler/components/shared/PanelSettingsList.css'; import './MarkerSettings.css'; +type OwnProps = { + readonly copyTable?: (format: 'plain' | 'markdown', onWarning: (message: string) => void) => void; +}; + type StateProps = { readonly searchString: string; readonly allowSwitchingStackType: boolean; @@ -29,7 +34,7 @@ type DispatchProps = { readonly changeMarkersSearchString: typeof changeMarkersSearchString; }; -type Props = ConnectedProps<{}, StateProps, DispatchProps>; +type Props = ConnectedProps; type State = { readonly isMarkerFiltersMenuVisible: boolean; @@ -39,12 +44,20 @@ type State = { // Otherwise, if we check this in onClick event, the state will always be // `false` since the library already hid it on mousedown. readonly isFilterMenuVisibleOnMouseDown: boolean; + readonly isMarkerCopyTableMenuVisible: boolean; + readonly isCopyTableMenuVisibleOnMouseDown: boolean; + readonly copyTableWarning: string | null; }; class MarkerSettingsImpl extends PureComponent { + _copyTableWarningTimeout: NodeJS.Timeout | null = null; + override state = { isMarkerFiltersMenuVisible: false, isFilterMenuVisibleOnMouseDown: false, + isMarkerCopyTableMenuVisible: false, + isCopyTableMenuVisibleOnMouseDown: false, + copyTableWarning: null, }; _onSearch = (value: string) => { @@ -72,6 +85,44 @@ class MarkerSettingsImpl extends PureComponent { }); }; + _onClickToggleCopyTableMenu = (event: React.MouseEvent) => { + const { isCopyTableMenuVisibleOnMouseDown } = this.state; + if (isCopyTableMenuVisibleOnMouseDown) { + // Do nothing as we would like to hide the menu if the menu was already visible on mouse down. + return; + } + + const rect = event.currentTarget.getBoundingClientRect(); + // FIXME: Currently we assume that the context menu is 250px wide, but ideally + // we should get the real width. It's not so easy though, because the context + // menu is not rendered yet. + const isRightAligned = rect.right > window.innerWidth - 250; + + showMenu({ + data: null, + id: 'MarkerCopyTableContextMenu', + position: { x: isRightAligned ? rect.right : rect.left, y: rect.bottom }, + target: event.target, + }); + }; + + _onCopyTable = (format: 'plain' | 'markdown') => { + const { copyTable } = this.props; + if (!copyTable) { + return; + } + + copyTable(format, (message: string) => { + this.setState({ copyTableWarning: message }); + if (this._copyTableWarningTimeout) { + clearTimeout(this._copyTableWarningTimeout); + } + this._copyTableWarningTimeout = setTimeout(() => { + this.setState({ copyTableWarning: null }); + }, 3000); + }); + }; + _onShowFiltersContextMenu = () => { this.setState({ isMarkerFiltersMenuVisible: true }); }; @@ -80,15 +131,33 @@ class MarkerSettingsImpl extends PureComponent { this.setState({ isMarkerFiltersMenuVisible: false }); }; + _onShowCopyTableContextMenu = () => { + this.setState({ isMarkerCopyTableMenuVisible: true }); + }; + + _onHideCopyTableContextMenu = () => { + this.setState({ isMarkerCopyTableMenuVisible: false }); + }; + _onMouseDownToggleFilterButton = () => { this.setState((state) => ({ isFilterMenuVisibleOnMouseDown: state.isMarkerFiltersMenuVisible, })); }; + _onMouseDownToggleCopyTableMenu = () => { + this.setState((state) => ({ + isCopyTableMenuVisibleOnMouseDown: state.isMarkerCopyTableMenuVisible, + })); + }; + override render() { - const { searchString, allowSwitchingStackType } = this.props; - const { isMarkerFiltersMenuVisible } = this.state; + const { searchString, allowSwitchingStackType, copyTable } = this.props; + const { + isMarkerFiltersMenuVisible, + isMarkerCopyTableMenuVisible, + copyTableWarning, + } = this.state; return (
@@ -99,6 +168,35 @@ class MarkerSettingsImpl extends PureComponent { ) : null} + {copyTable ? ( + +
); } } -export const MarkerSettings = explicitConnect<{}, StateProps, DispatchProps>({ +export const MarkerSettings = explicitConnect< + OwnProps, + StateProps, + DispatchProps +>({ mapStateToProps: (state) => ({ searchString: getMarkersSearchString(state), allowSwitchingStackType: getProfileUsesMultipleStackTypes(state), diff --git a/src/test/components/MarkerTable.test.tsx b/src/test/components/MarkerTable.test.tsx index 2d381ff82a..8133884e6c 100644 --- a/src/test/components/MarkerTable.test.tsx +++ b/src/test/components/MarkerTable.test.tsx @@ -515,6 +515,54 @@ describe('MarkerTable', function () { expect(firstColumn).toHaveStyle({ width: '90px' }); }); }); + + it('can copy the table as plain text', () => { + const { container } = setup(); + + const button = ensureExists( + container.querySelector('.copyTableButton') + ) as HTMLElement; + fireFullClick(button); + + const menu = ensureExists( + container.querySelector('.markerCopyTableContextMenu') + ) as HTMLElement; + + const items = menu.querySelectorAll('[role="menuitem"]'); + expect(items.length).toBe(2); + + fireFullClick(items[0] as HTMLElement); + + const pattern = new RegExp( + '^ +Start +Duration +Name +Details\\n +0s +0s +UserTiming +foobar\\n' + ); + expect(copy).toHaveBeenLastCalledWith(expect.stringMatching(pattern)); + }); + + it('can copy the table as markdown', () => { + const { container } = setup(); + + const button = ensureExists( + container.querySelector('.copyTableButton') + ) as HTMLElement; + fireFullClick(button); + + const menu = ensureExists( + container.querySelector('.markerCopyTableContextMenu') + ) as HTMLElement; + + const items = menu.querySelectorAll('[role="menuitem"]'); + expect(items.length).toBe(2); + + fireFullClick(items[1] as HTMLElement); + + const pattern = new RegExp( + '^\\| +Start +\\| +Duration +\\| +Name +\\| +Details +\\|\\n' + + '\\|-+:\\|-+:\\|-+:\\|-+\\|\\n' + + '\\| +0s +\\| +0s +\\| +UserTiming +\\| +foobar +\\|\\n' + ); + expect(copy).toHaveBeenLastCalledWith(expect.stringMatching(pattern)); + }); }); function getReflowMarker( diff --git a/src/test/components/__snapshots__/MarkerChart.test.tsx.snap b/src/test/components/__snapshots__/MarkerChart.test.tsx.snap index 68263b885b..921e5dc818 100644 --- a/src/test/components/__snapshots__/MarkerChart.test.tsx.snap +++ b/src/test/components/__snapshots__/MarkerChart.test.tsx.snap @@ -553,6 +553,31 @@ exports[`MarkerChart renders the normal marker chart and matches the snapshot 1` +
+ +
+
+
+ +