From 7ff2708dc50445a9d9a886c8c206e7a761edabe9 Mon Sep 17 00:00:00 2001 From: Tooru Fujisawa Date: Tue, 23 Dec 2025 23:20:12 +0900 Subject: [PATCH 1/2] Support negative timestamp --- src/utils/format-numbers.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/utils/format-numbers.ts b/src/utils/format-numbers.ts index 77ecb185a7..3698200890 100644 --- a/src/utils/format-numbers.ts +++ b/src/utils/format-numbers.ts @@ -425,7 +425,19 @@ export function formatTimestamp( maxFractionalDigits: number = 3, // precision is the minimum required precision. precision: Milliseconds = Infinity -) { +): string { + if (time < 0) { + return ( + '-' + + formatTimestamp( + Math.abs(time), + significantDigits, + maxFractionalDigits, + precision + ) + ); + } + if (precision !== Infinity) { // Round the values to display nicer numbers when the extra precision // isn't useful. (eg. show 3h52min10s instead of 3h52min14s) From 1ca1a1ce8ddd980e45a3e0b8fc08312aa36ff3b2 Mon Sep 17 00:00:00 2001 From: Tooru Fujisawa Date: Sat, 10 Jan 2026 20:21:49 +0900 Subject: [PATCH 2/2] Make it possible to override the zeroAt value --- locales/en-US/app.ftl | 8 ++ res/img/svg/override-zero-at-marker-start.svg | 10 +++ src/actions/profile-view.ts | 7 ++ src/components/app/MenuButtons/index.css | 9 ++ src/components/app/MenuButtons/index.tsx | 44 +++++++++- src/components/shared/MarkerContextMenu.css | 4 + src/components/shared/MarkerContextMenu.tsx | 17 ++++ src/reducers/profile-view.ts | 10 +++ src/selectors/profile.ts | 20 ++++- src/test/components/MarkerChart.test.tsx | 6 +- src/test/components/MarkerTable.test.tsx | 19 +++++ src/test/components/ProfileViewer.test.tsx | 85 ++++++++++++++++++- .../__snapshots__/MarkerChart.test.tsx.snap | 14 +++ src/types/actions.ts | 4 + src/types/state.ts | 1 + 15 files changed, 249 insertions(+), 9 deletions(-) create mode 100644 res/img/svg/override-zero-at-marker-start.svg diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index fc0ec8bf2d..2b3e8e302e 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -438,6 +438,8 @@ MarkerContextMenu--end-selection-at-marker-start = End selection at marker’s start MarkerContextMenu--end-selection-at-marker-end = End selection at marker’s end +MarkerContextMenu--align-timeline-start-with-marker-start = + Align the timeline start with the marker start MarkerContextMenu--copy-description = Copy description MarkerContextMenu--copy-call-stack = Copy call stack MarkerContextMenu--copy-url = Copy URL @@ -702,6 +704,12 @@ MenuButtons--publish--download = Download MenuButtons--publish--compressing = Compressing… MenuButtons--publish--error-while-compressing = Error while compressing, try unchecking some checkboxes to reduce the profile size. +# This string is the button's label, where the button is shown when the +# timeline start is aligned to certain marker. +# Variables: +# $zeroAt (String) - The timestamp of the starting point +MenuButtons--starting-point-moved = Starting point moved to { $zeroAt } + ## NetworkSettings ## This is used in the network chart. diff --git a/res/img/svg/override-zero-at-marker-start.svg b/res/img/svg/override-zero-at-marker-start.svg new file mode 100644 index 0000000000..6b7a84802a --- /dev/null +++ b/res/img/svg/override-zero-at-marker-start.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 47c50d4017..a87ade1316 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -1905,6 +1905,13 @@ export function changeMouseTimePosition( }; } +export function overrideZeroAt(zeroAt: Milliseconds | null): Action { + return { + type: 'OVERRIDE_ZERO_AT', + zeroAt, + }; +} + export function changeTableViewOptions( tab: TabSlug, tableViewOptions: TableViewOptions diff --git a/src/components/app/MenuButtons/index.css b/src/components/app/MenuButtons/index.css index 88f34acab1..dbc8259ab7 100644 --- a/src/components/app/MenuButtons/index.css +++ b/src/components/app/MenuButtons/index.css @@ -111,6 +111,15 @@ background-image: url(../../../../res/img/svg/maximize-dark-12.svg); } +.menuButtonsResetZeroAtButton::before { + background-image: url(../../../../res/img/svg/undo-dark-12.svg); +} + +.menuButtonsZeroAtTimestamp { + font-weight: bold; + margin-inline-start: 0.3em; +} + .profileInfoUploadedActions { padding: 8px 0 8px 40px; /* The 40px padding leaves the room for the cloud image */ border-bottom: 1px solid rgb(0 0 0 / 0.05); diff --git a/src/components/app/MenuButtons/index.tsx b/src/components/app/MenuButtons/index.tsx index fc2d965154..0df718e282 100644 --- a/src/components/app/MenuButtons/index.tsx +++ b/src/components/app/MenuButtons/index.tsx @@ -11,7 +11,10 @@ import classNames from 'classnames'; import { Localized } from '@fluent/react'; import explicitConnect from 'firefox-profiler/utils/connect'; -import { getProfileRootRange } from 'firefox-profiler/selectors/profile'; +import { + getProfileRootRange, + getOverriddenZeroAtTimestamp, +} from 'firefox-profiler/selectors/profile'; import { getDataSource, getProfileUrl, @@ -36,6 +39,7 @@ import { dismissNewlyPublished, profileRemotelyDeleted, } from 'firefox-profiler/actions/app'; +import { overrideZeroAt } from 'firefox-profiler/actions/profile-view'; import { getAbortFunction, @@ -72,12 +76,14 @@ type StateProps = { readonly hasPrePublishedState: boolean; readonly abortFunction: () => void; readonly currentProfileUploadedInformation: UploadedProfileInformation | null; + readonly getOverriddenZeroAtTimestamp: string | null; }; type DispatchProps = { readonly dismissNewlyPublished: typeof dismissNewlyPublished; readonly revertToPrePublishedState: typeof revertToPrePublishedState; readonly profileRemotelyDeleted: typeof profileRemotelyDeleted; + readonly overrideZeroAt: typeof overrideZeroAt; }; type Props = ConnectedProps; @@ -130,6 +136,11 @@ class MenuButtonsImpl extends React.PureComponent { }); }; + _resetZeroAt = () => { + const { overrideZeroAt } = this.props; + overrideZeroAt(null); + }; + _renderUploadedProfileActions( currentProfileUploadedInformation: UploadedProfileInformation ) { @@ -309,6 +320,34 @@ class MenuButtonsImpl extends React.PureComponent { ) : null; } + _renderZeroAt() { + const { getOverriddenZeroAtTimestamp } = this.props; + if (getOverriddenZeroAtTimestamp === null) { + return null; + } + + return ( + + ); + } + _renderRevertProfile() { const { hasPrePublishedState, revertToPrePublishedState } = this.props; if (!hasPrePublishedState) { @@ -330,6 +369,7 @@ class MenuButtonsImpl extends React.PureComponent { override render() { return ( <> + {this._renderZeroAt()} {this._renderRevertProfile()} {this._renderMetaInfoButton()} {this._renderPublishPanel()} @@ -360,11 +400,13 @@ export const MenuButtons = explicitConnect( abortFunction: getAbortFunction(state), currentProfileUploadedInformation: getCurrentProfileUploadedInformation(state), + getOverriddenZeroAtTimestamp: getOverriddenZeroAtTimestamp(state), }), mapDispatchToProps: { dismissNewlyPublished, revertToPrePublishedState, profileRemotelyDeleted, + overrideZeroAt, }, component: MenuButtonsImpl, } diff --git a/src/components/shared/MarkerContextMenu.css b/src/components/shared/MarkerContextMenu.css index 851877cfac..9be82b7448 100644 --- a/src/components/shared/MarkerContextMenu.css +++ b/src/components/shared/MarkerContextMenu.css @@ -31,6 +31,10 @@ background-image: url(../../../res/img/svg/end-selection-at-marker-end.svg); } +.markerContextMenuIconOverrideZeroAtMarkerStart { + background-image: url(../../../res/img/svg/override-zero-at-marker-start.svg); +} + .markerContextMenuIconCopyDescription { background-image: url(../../../res/img/svg/copy-dark.svg); } diff --git a/src/components/shared/MarkerContextMenu.tsx b/src/components/shared/MarkerContextMenu.tsx index c1243223d8..38167bfc84 100644 --- a/src/components/shared/MarkerContextMenu.tsx +++ b/src/components/shared/MarkerContextMenu.tsx @@ -13,6 +13,7 @@ import { setContextMenuVisibility, updatePreviewSelection, selectTrackFromTid, + overrideZeroAt, } from 'firefox-profiler/actions/profile-view'; import { getPreviewSelection, @@ -65,6 +66,7 @@ type DispatchProps = { readonly updatePreviewSelection: typeof updatePreviewSelection; readonly setContextMenuVisibility: typeof setContextMenuVisibility; readonly selectTrackFromTid: typeof selectTrackFromTid; + readonly overrideZeroAt: typeof overrideZeroAt; }; type Props = ConnectedProps; @@ -141,6 +143,11 @@ class MarkerContextMenuImpl extends PureComponent { }); }; + overrideZeroAtMarkerStart = () => { + const { marker, overrideZeroAt } = this.props; + overrideZeroAt(marker.start); + }; + _isZeroDurationMarker(marker: Marker | null): boolean { return !marker || marker.end === null; } @@ -482,6 +489,15 @@ class MarkerContextMenuImpl extends PureComponent { )} +
+ + + + + Align the timeline start with the marker start + + +
@@ -539,6 +555,7 @@ const MarkerContextMenu = explicitConnect({ updatePreviewSelection, setContextMenuVisibility, selectTrackFromTid, + overrideZeroAt, }, component: MarkerContextMenuImpl, }); diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index 26465510fd..8c9aa7f5dc 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -824,6 +824,15 @@ const mouseTimePosition: Reducer = ( } }; +const overrideZeroAt: Reducer = (state = null, action) => { + switch (action.type) { + case 'OVERRIDE_ZERO_AT': + return action.zeroAt; + default: + return state; + } +}; + /** * Provide a mechanism to wrap the reducer in a special function that can reset * the state to the default values. This is useful when viewing multiple profiles @@ -864,6 +873,7 @@ const profileViewReducer: Reducer = wrapReducerInResetter( hoveredMarker, mouseTimePosition, perTab: tableViewOptionsPerTab, + overrideZeroAt, }), profile, globalTracks, diff --git a/src/selectors/profile.ts b/src/selectors/profile.ts index d761943a3f..59ca5d47ff 100644 --- a/src/selectors/profile.ts +++ b/src/selectors/profile.ts @@ -6,6 +6,7 @@ import * as Tracks from '../profile-logic/tracks'; import * as CPU from '../profile-logic/cpu'; import * as UrlState from './url-state'; import { ensureExists } from '../utils/types'; +import { formatTimestamp } from '../utils/format-numbers'; import { accumulateCounterSamples, extractProfileFilterPageData, @@ -96,8 +97,23 @@ export const getScrollToSelectionGeneration: Selector = (state) => getProfileViewOptions(state).scrollToSelectionGeneration; export const getFocusCallTreeGeneration: Selector = (state) => getProfileViewOptions(state).focusCallTreeGeneration; -export const getZeroAt: Selector = (state) => - getProfileRootRange(state).start; +export const getZeroAt: Selector = (state) => { + const viewOptions = getProfileViewOptions(state); + if (viewOptions.overrideZeroAt !== null) { + return viewOptions.overrideZeroAt; + } + return getProfileRootRange(state).start; +}; +export const getOverriddenZeroAtTimestamp: Selector = ( + state +) => { + const viewOptions = getProfileViewOptions(state); + if (viewOptions.overrideZeroAt === null) { + return null; + } + const offset = viewOptions.overrideZeroAt - getProfileRootRange(state).start; + return formatTimestamp(offset); +}; export const getProfileTimelineUnit: Selector = (state) => { const { sampleUnits } = getProfile(state).meta; return sampleUnits ? sampleUnits.time : 'ms'; diff --git a/src/test/components/MarkerChart.test.tsx b/src/test/components/MarkerChart.test.tsx index c72a4e5951..e9b6e1bdff 100644 --- a/src/test/components/MarkerChart.test.tsx +++ b/src/test/components/MarkerChart.test.tsx @@ -652,14 +652,14 @@ describe('MarkerChart', function () { expect(getContextMenu()).toHaveClass('react-contextmenu--visible'); - clickOnMenuItem(/start.*start/i); + clickOnMenuItem(/^start.*start/i); expect(getPreviewSelection(getState())).toEqual({ isModifying: false, selectionStart: 2, selectionEnd: 11, }); - clickOnMenuItem(/start.*end/i); + clickOnMenuItem(/^start.*end/i); expect(getPreviewSelection(getState())).toEqual({ isModifying: false, selectionStart: 8, @@ -676,7 +676,7 @@ describe('MarkerChart', function () { // Reset the selection by using the other marker. rightClick(findFillTextPosition('UserTiming A')); - clickOnMenuItem(/start.*start/i); + clickOnMenuItem(/^start.*start/i); expect(getPreviewSelection(getState())).toEqual({ isModifying: false, selectionStart: 0, diff --git a/src/test/components/MarkerTable.test.tsx b/src/test/components/MarkerTable.test.tsx index 9f9f16b6df..b86babbb9c 100644 --- a/src/test/components/MarkerTable.test.tsx +++ b/src/test/components/MarkerTable.test.tsx @@ -240,6 +240,25 @@ describe('MarkerTable', function () { ); }); + it('can align the timeline start to marker using the context menu', () => { + const { getRowElement } = setup(); + + const startNode = ensureExists( + getRowElement(/setTimeout/).querySelector('.start') + ); + expect(startNode).toHaveTextContent('0.153s'); + + fireFullContextMenu(getRowElement(/setTimeout/) as HTMLElement); + fireFullClick( + screen.getByText('Align the timeline start with the marker start') + ); + + const startNode2 = ensureExists( + getRowElement(/setTimeout/).querySelector('.start') + ); + expect(startNode2).toHaveTextContent('0s'); + }); + describe('EmptyReasons', () => { it('shows reasons when a profile has no non-network markers', () => { const { profile } = getProfileFromTextSamples('A'); // Just a simple profile without any marker. diff --git a/src/test/components/ProfileViewer.test.tsx b/src/test/components/ProfileViewer.test.tsx index e5fba9e71f..fc3ffd3cdf 100644 --- a/src/test/components/ProfileViewer.test.tsx +++ b/src/test/components/ProfileViewer.test.tsx @@ -4,7 +4,10 @@ import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; -import { render } from 'firefox-profiler/test/fixtures/testing-library'; +import { + render, + fireEvent, +} from 'firefox-profiler/test/fixtures/testing-library'; import { ProfileViewer } from 'firefox-profiler/components/app/ProfileViewer'; import { getTimelineHeight } from 'firefox-profiler/selectors/app'; import { updateUrlState } from 'firefox-profiler/actions/app'; @@ -13,6 +16,7 @@ import { stateFromLocation } from 'firefox-profiler/app-logic/url-handling'; import { blankStore } from '../fixtures/stores'; import { getProfileWithNiceTracks } from '../fixtures/profiles/tracks'; +import { getMarkerTableProfile } from '../fixtures/profiles/processed-profile'; import { autoMockCanvasContext } from '../fixtures/mocks/canvas-context'; import { mockRaf } from '../fixtures/mocks/request-animation-frame'; import { @@ -34,7 +38,7 @@ describe('ProfileViewer', function () { ); }); - function setup() { + function setup(profile = getProfileWithNiceTracks()) { // WithSize uses requestAnimationFrame const flushRafCalls = mockRaf(); @@ -48,7 +52,7 @@ describe('ProfileViewer', function () { }) ) ); - store.dispatch(viewProfile(getProfileWithNiceTracks())); + store.dispatch(viewProfile(profile)); const renderResult = render( @@ -68,4 +72,79 @@ describe('ProfileViewer', function () { // Note: You should update this total height if you changed the height calculation algorithm. expect(getTimelineHeight(getState())).toBe(1224); }); + + it('does not show a button to reset the zeroAt when not overridden', () => { + const { container } = setup(getMarkerTableProfile()); + + const button = container.querySelector( + '.menuButtonsResetZeroAtButton' + )! as HTMLElement; + expect(button).toBeNull(); + }); + + it('shows a button to reset the zeroAt when overridden', () => { + const { container, getByText } = setup(getMarkerTableProfile()); + + const tab = getByText('Marker Table'); + fireEvent.click(tab); + + const row1 = container.querySelector( + '.treeViewRowFixedColumns:nth-child(1)' + )! as HTMLElement; + { + const start = row1.querySelector('.start')! as HTMLElement; + expect(start).toHaveTextContent('0s'); + } + + const row3 = container.querySelector( + '.treeViewRowFixedColumns:nth-child(3)' + )! as HTMLElement; + { + const start = row3.querySelector('.start')! as HTMLElement; + expect(start).toHaveTextContent('0.108s'); + } + + // The following mousedown will trigger a warning due to the limitation + // on the test env. + jest.spyOn(console, 'warn').mockImplementation(() => {}); + fireEvent.mouseDown(row3, { button: 2 }); + + const item = container.querySelector( + '.markerContextMenuIconOverrideZeroAtMarkerStart' + )! as HTMLElement; + + fireEvent.click(item); + + { + const start = row1.querySelector('.start')! as HTMLElement; + expect(start).toHaveTextContent('-0.108s'); + } + { + const start = row3.querySelector('.start')! as HTMLElement; + expect(start).toHaveTextContent('0s'); + } + + // After overriding the zeroAt, the button should be shown. + const button = container.querySelector( + '.menuButtonsResetZeroAtButton' + )! as HTMLElement; + expect(button).toHaveTextContent('Starting point moved to ⁨107.50ms⁩'); + + // Clicking the button should reset the override. + fireEvent.click(button); + + const button2 = container.querySelector( + '.menuButtonsResetZeroAtButton' + )! as HTMLElement; + expect(button2).toBeNull(); + + { + const start = row1.querySelector('.start')! as HTMLElement; + expect(start).toHaveTextContent('0s'); + } + { + const start = row3.querySelector('.start')! as HTMLElement; + expect(start).toHaveTextContent('0.108s'); + } + }); }); diff --git a/src/test/components/__snapshots__/MarkerChart.test.tsx.snap b/src/test/components/__snapshots__/MarkerChart.test.tsx.snap index 830ed9f030..090b05441f 100644 --- a/src/test/components/__snapshots__/MarkerChart.test.tsx.snap +++ b/src/test/components/__snapshots__/MarkerChart.test.tsx.snap @@ -133,6 +133,20 @@ exports[`MarkerChart context menus displays when right clicking on a marker 1`]
+ +