diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 91c30908bb..f7620a44a3 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -775,6 +775,8 @@ MenuButtons--publish--cancel-upload = Cancel Upload MenuButtons--publish--message-something-went-wrong = Uh oh, something went wrong when uploading the profile. MenuButtons--publish--message-try-again = Try again MenuButtons--publish--download = Download +MenuButtons--publish--download-json = Download as JSON +MenuButtons--publish--download-json-error = Error preparing JSON download MenuButtons--publish--compressing = Compressing… MenuButtons--publish--error-while-compressing = Error while compressing, try unchecking some checkboxes to reduce the profile size. diff --git a/src/actions/publish.ts b/src/actions/publish.ts index f4b3869bd2..391044d46f 100644 --- a/src/actions/publish.ts +++ b/src/actions/publish.ts @@ -60,9 +60,14 @@ import type { ProfileIndexTranslationMaps, ProfileEncodingResult, SharingMode, + PublishProfileFormat, } from 'firefox-profiler/types'; import { compress } from 'firefox-profiler/utils/gz'; -import { serializeProfileToJsonString } from 'firefox-profiler/profile-logic/process-profile'; +import { + optimizeProfileForStorage, + serializeProfileToJsonSlabsFile, + serializeProfileToJsonString, +} from 'firefox-profiler/profile-logic/process-profile'; export function updateSharingOption( mode: SharingMode, @@ -78,33 +83,45 @@ export function updateSharingOption( } export function sanitizedProfileEncodingStarted( + mode: SharingMode, + format: PublishProfileFormat, sanitizedProfile: Profile, encodingPromise: Promise ): Action { return { type: 'SANITIZED_PROFILE_ENCODING_STARTED', + mode, + format, sanitizedProfile, encodingPromise, }; } export function sanitizedProfileEncodingCompleted( + mode: SharingMode, + format: PublishProfileFormat, sanitizedProfile: Profile, profileData: Blob ): Action { return { type: 'SANITIZED_PROFILE_ENCODING_COMPLETED', + mode, + format, sanitizedProfile, profileData, }; } export function sanitizedProfileEncodingFailed( + mode: SharingMode, + format: PublishProfileFormat, sanitizedProfile: Profile, error: Error ): Action { return { type: 'SANITIZED_PROFILE_ENCODING_FAILED', + mode, + format, sanitizedProfile, error, }; @@ -268,10 +285,21 @@ function unwrapEncodedProfile(encodingResult: ProfileEncodingResult): Blob { return encodingResult.profileData; } -export type InflightProfileEncoding = { - sanitizedProfile: Profile; - encodingPromise: Promise; -}; +function serializeSanitizedProfile( + format: PublishProfileFormat, + sanitizedProfile: Profile +): string | Uint8Array { + switch (format) { + case 'jslb': + return serializeProfileToJsonSlabsFile( + optimizeProfileForStorage(sanitizedProfile) + ); + case 'json': + return serializeProfileToJsonString(sanitizedProfile); + default: + throw new Error(`Unknown publish format: ${format as string}`); + } +} /** * Kick off "encoding" of the sanitized profile. Specifically this means: @@ -281,7 +309,7 @@ export type InflightProfileEncoding = { * * The asynchronous compression can take a few seconds, so we kick it off * immediately when the download or share panel is opened. The in-flight - * compression is tracked in the redux state, so opening the other panel or + * compression is tracked in the redux state, so reopening the panel or * pressing Upload reuses it (for the same sanitized profile) instead of * compressing again. The returned promise lets the caller wait on the * compressed result. @@ -289,61 +317,69 @@ export type InflightProfileEncoding = { * This thunk action is synchronous. */ export function encodeSanitizedProfile( - mode: SharingMode -): ThunkAction { - return (dispatch, getState): InflightProfileEncoding => { + mode: SharingMode, + format: PublishProfileFormat +): ThunkAction> { + return (dispatch, getState): Promise => { const state = getState(); const sanitizedProfile = getSanitizedProfile(state, mode).profile; - const encodingState = getSanitizedProfileEncodingState(state); + const encodingState = getSanitizedProfileEncodingState(state, mode, format); if ( encodingState.phase === 'ENCODING' && encodingState.sanitizedProfile === sanitizedProfile ) { // A compression for this profile is already in-flight; reuse it. - return { - sanitizedProfile, - encodingPromise: encodingState.encodingPromise, - }; + return encodingState.encodingPromise; } if ( encodingState.phase === 'DONE' && encodingState.sanitizedProfile === sanitizedProfile ) { // We already have an encoded version of this profile in our state! Use it. - return { - sanitizedProfile, - encodingPromise: Promise.resolve({ - type: 'SUCCESS', - profileData: encodingState.profileData, - }), - }; + return Promise.resolve({ + type: 'SUCCESS', + profileData: encodingState.profileData, + }); } // Kick off a new encoding for this profile. Don't await the promise, - // just return it as part of the InflightProfileEncoding. + // just hand it back to the caller. const encodingPromise: Promise = (async function () { // Yield so the ENCODING phase (dispatched below) is in the store before a // synchronous failure here could dispatch FAILED, which the reducer drops. await Promise.resolve(); try { - const gzipData = await compress( - serializeProfileToJsonString(sanitizedProfile) - ); + const serialized = serializeSanitizedProfile(format, sanitizedProfile); + const gzipData = await compress(serialized); const blob = new Blob([gzipData], { type: 'application/octet-binary' }); - dispatch(sanitizedProfileEncodingCompleted(sanitizedProfile, blob)); + dispatch( + sanitizedProfileEncodingCompleted( + mode, + format, + sanitizedProfile, + blob + ) + ); return { type: 'SUCCESS', profileData: blob }; } catch (error) { - dispatch(sanitizedProfileEncodingFailed(sanitizedProfile, error)); + dispatch( + sanitizedProfileEncodingFailed(mode, format, sanitizedProfile, error) + ); console.error('Error while compressing the profile data', error); return { type: 'ERROR', error }; } })(); dispatch( - sanitizedProfileEncodingStarted(sanitizedProfile, encodingPromise) + sanitizedProfileEncodingStarted( + mode, + format, + sanitizedProfile, + encodingPromise + ) ); - return { sanitizedProfile, encodingPromise }; + return encodingPromise; }; } @@ -394,8 +430,9 @@ export function attemptToPublish(): ThunkAction> { prePublishedState, 'upload' ); - const profileEncoding = dispatch(encodeSanitizedProfile('upload')); - const encodingResult = await profileEncoding.encodingPromise; + const encodingResult = await dispatch( + encodeSanitizedProfile('upload', 'jslb') + ); // The previous line was async, check to make sure that this request is still valid. // The upload could have been aborted while we were compressing the data. diff --git a/src/components/app/MenuButtons/Publish.css b/src/components/app/MenuButtons/Publish.css index 4d6d4ca9b1..1162af6384 100644 --- a/src/components/app/MenuButtons/Publish.css +++ b/src/components/app/MenuButtons/Publish.css @@ -170,6 +170,54 @@ background-image: url(../../../../res/img/svg/download-light.svg); } +/* Split-button styling: primary "Download" button on the left, dropdown + * toggle on the right, visually joined. */ +.publishPanelSplitButton { + display: inline-flex; + margin: 0 8px; +} + +.publishPanelSplitButtonMain { + margin: 0; + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} + +.publishPanelSplitButtonToggleButton { + position: relative; + width: 24px; + min-width: 0; + height: 32px; + padding: 0; + border-left: 1px solid var(--grey-30-a60, rgb(12 12 13 / 0.2)); + border-bottom-left-radius: 0; + border-top-left-radius: 0; + cursor: default; +} + +/* Down-caret drawn with borders, matching the marker-chart filter/copy + * buttons' style. */ +.publishPanelSplitButtonToggleButton::after { + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-top: 5px solid; + border-right: 4px solid transparent; + border-left: 4px solid transparent; + color: var(--clickable-foreground-color); + content: ''; + transform: translate(-50%, -25%); +} + +/* Pin the download-format menu to a fixed width. The JS side subtracts this + * width from the toggle button's right edge to right-anchor the menu; the + * two must stay in sync. */ +.publishPanelDownloadFormatContextMenu { + width: 220px; +} + .menuButtonsDownloadSize { display: inline-block; margin: 0 4px; diff --git a/src/components/app/MenuButtons/Publish.tsx b/src/components/app/MenuButtons/Publish.tsx index 413b8c49f6..ff9af1f56a 100644 --- a/src/components/app/MenuButtons/Publish.tsx +++ b/src/components/app/MenuButtons/Publish.tsx @@ -20,15 +20,17 @@ import { import { getAbortFunction, getCheckedSharingOptions, - getFilenameString, + getDownloadFilename, getUploadPhase, getUploadProgress, getUploadProgressString, getUploadError, getShouldSanitizeByDefault, - getSanitizedProfileEncodingState, + getSanitizedProfileEncodingStates, } from 'firefox-profiler/selectors/publish'; import { BlobUrlLink } from 'firefox-profiler/components/shared/BlobUrlLink'; +import { ContextMenu } from 'firefox-profiler/components/shared/ContextMenu'; +import { MenuItem, showMenu } from '@firefox-devtools/react-contextmenu'; import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; import prettyBytes from 'firefox-profiler/utils/pretty-bytes'; @@ -45,6 +47,7 @@ import type { StartEndRange, UploadPhase, SanitizedProfileEncodingState, + PublishProfileFormat, } from 'firefox-profiler/types'; import './Publish.css'; @@ -62,8 +65,12 @@ type StateProps = { readonly profileContainsPrivateBrowsingInformation: boolean; readonly profileHasJSTracingArgumentValues: boolean; readonly checkedSharingOptions: CheckedSharingOptions; - readonly sanitizedProfileEncodingState: SanitizedProfileEncodingState; - readonly downloadFileName: string; + readonly sanitizedProfileEncodingStates: Record< + PublishProfileFormat, + SanitizedProfileEncodingState + >; + readonly jslbDownloadFileName: string; + readonly jsonDownloadFileName: string; readonly uploadPhase: UploadPhase; readonly uploadProgress: number; readonly uploadProgressString: string; @@ -83,9 +90,13 @@ type PublishProps = ConnectedProps; class PublishPanelImpl extends React.PureComponent { override componentDidMount(): void { - this.props.encodeSanitizedProfile(this.props.mode); + this._refreshEncoding('jslb'); } + _refreshEncoding = (format: PublishProfileFormat) => { + this.props.encodeSanitizedProfile(this.props.mode, format); + }; + _onCheckboxChange = (e: React.ChangeEvent) => { const sharingOption = e.target.name as keyof CheckedSharingOptions; this.props.updateSharingOption( @@ -94,7 +105,16 @@ class PublishPanelImpl extends React.PureComponent { e.target.checked ); - this.props.encodeSanitizedProfile(this.props.mode); + this._refreshEncoding('jslb'); + // Only keep the JSON encoding fresh if it has already been requested at + // least once (i.e. the user has opened the download dropdown). + if (this.props.sanitizedProfileEncodingStates.json.phase !== 'INITIAL') { + this._refreshEncoding('json'); + } + }; + + _onJsonDropdownOpen = () => { + this._refreshEncoding('json'); }; _renderCheckbox( @@ -102,9 +122,13 @@ class PublishPanelImpl extends React.PureComponent { labelL10nId: string, additionalContent?: React.ReactNode ) { - const { checkedSharingOptions, uploadPhase } = this.props; + const { checkedSharingOptions, uploadPhase, mode } = this.props; + // Only the panel whose upload is in flight needs to freeze its options. + // The two modes have independent options, so an upload started from Share + // is no reason to stop someone adjusting what they save to disk. const isUploading = - uploadPhase === 'uploading' || uploadPhase === 'compressing'; + mode === 'upload' && + (uploadPhase === 'uploading' || uploadPhase === 'compressing'); return (