diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 196f00f2f3..d6b97ecd6c 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -926,21 +926,25 @@ TransformNavigator--collapse-indirect-recursion = Collapse indirect recursion: { # $item (String) - Name of the function that transform applied to. TransformNavigator--collapse-function-subtree = Collapse subtree: { $item } -## Source code view in a box at the bottom of the UI. +## "Bottom box" - a view which contains the source view and the assembly view, +## at the bottom of the profiler UI +## +## Some of these string IDs still start with SourceView, even though the strings +## are used for both the source view and the assembly view. -# Displayed while the source view is waiting for the network request which -# delivers the source code. +# Displayed while a view in the bottom box is waiting for code to load from +# the network. # Variables: # $host (String) - The "host" part of the URL, e.g. hg.mozilla.org SourceView--loading-url = Waiting for { $host }… -# Displayed while the source view is waiting for the browser to deliver -# the source code. +# Displayed while a view in the bottom box is waiting for code to load from +# the browser. SourceView--loading-browser-connection = Waiting for { -firefox-brand-name }… # Displayed whenever the source view was not able to get the source code for # a file. -SourceView--source-not-available-title = Source not available +BottomBox--source-code-not-available-title = Source code not available # Displayed whenever the source view was not able to get the source code for # a file. @@ -949,6 +953,25 @@ SourceView--source-not-available-title = Source not available SourceView--source-not-available-text = See issue #3741 for supported scenarios and planned improvements. +# Displayed whenever the assembly view was not able to get the assembly code for +# a file. +# Assembly refers to the low-level programming language. +BottomBox--assembly-code-not-available-title = Assembly code not available + +# Displayed whenever the assembly view was not able to get the assembly code for +# a file. +# Elements: +# link text - A link to the github issue about supported scenarios. +BottomBox--assembly-code-not-available-text = + See issue #4520 for supported scenarios and planned improvements. + +SourceView--close-button = + .title = Close the source view + +## Code loading errors +## These are displayed both in the source view and in the assembly view. +## The string IDs here currently all start with SourceView for historical reasons. + # Displayed below SourceView--cannot-obtain-source, if the profiler does not # know which URL to request source code from. SourceView--no-known-cors-url = @@ -1016,8 +1039,17 @@ SourceView--not-in-archive-error-when-obtaining-source = SourceView--archive-parsing-error-when-obtaining-source = The archive at { $url } could not be parsed: { $parsingErrorMessage } -SourceView--close-button = - .title = Close the source view +## Toggle buttons in the top right corner of the bottom box + +# The toggle button for the assembly view, while the assembly view is hidden. +# Assembly refers to the low-level programming language. +AssemblyView--show-button = + .title = Show the assembly view + +# The toggle button for the assembly view, while the assembly view is shown. +# Assembly refers to the low-level programming language. +AssemblyView--hide-button = + .title = Hide the assembly view ## UploadedRecordingsHome ## This is the page that displays all the profiles that user has uploaded. diff --git a/res/css/photon/button.css b/res/css/photon/button.css index 024e0819a2..9a37ddf7d2 100644 --- a/res/css/photon/button.css +++ b/res/css/photon/button.css @@ -80,7 +80,8 @@ background-color: transparent; } -.photon-button-ghost:hover:not(:disabled) { +.photon-button-ghost:hover:not(:disabled), +.photon-button-ghost--checked { background-color: var(--grey-90-a10); } diff --git a/res/img/svg/asm-icon.svg b/res/img/svg/asm-icon.svg new file mode 100644 index 0000000000..f7adf26afd --- /dev/null +++ b/res/img/svg/asm-icon.svg @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/src/actions/code.js b/src/actions/code.js index fddbe025d8..2afa84c49b 100644 --- a/src/actions/code.js +++ b/src/actions/code.js @@ -23,8 +23,8 @@ export function beginLoadingSourceCodeFromBrowserConnection( return { type: 'SOURCE_CODE_LOADING_BEGIN_BROWSER_CONNECTION', file }; } -export function finishLoadingSourceCode(file: string, source: string): Action { - return { type: 'SOURCE_CODE_LOADING_SUCCESS', file, source }; +export function finishLoadingSourceCode(file: string, code: string): Action { + return { type: 'SOURCE_CODE_LOADING_SUCCESS', file, code }; } export function failLoadingSourceCode( diff --git a/src/components/app/AssemblyViewToggleButton.js b/src/components/app/AssemblyViewToggleButton.js new file mode 100644 index 0000000000..c511f80d98 --- /dev/null +++ b/src/components/app/AssemblyViewToggleButton.js @@ -0,0 +1,87 @@ +/* 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/. */ +// @flow + +import React from 'react'; +import classNames from 'classnames'; + +import { getAssemblyViewIsOpen } from 'firefox-profiler/selectors/url-state'; +import { + openAssemblyView, + closeAssemblyView, +} from 'firefox-profiler/actions/profile-view'; +import explicitConnect from 'firefox-profiler/utils/connect'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import { Localized } from '@fluent/react'; + +type StateProps = {| + +assemblyViewIsOpen: boolean, +|}; + +type DispatchProps = {| + +openAssemblyView: typeof openAssemblyView, + +closeAssemblyView: typeof closeAssemblyView, +|}; + +type Props = ConnectedProps<{||}, StateProps, DispatchProps>; + +class AssemblyViewToggleButtonImpl extends React.PureComponent { + _onClick = () => { + if (this.props.assemblyViewIsOpen) { + this.props.closeAssemblyView(); + } else { + this.props.openAssemblyView(); + } + }; + + render() { + const { assemblyViewIsOpen } = this.props; + + return assemblyViewIsOpen ? ( + + + + ) : ( + + + + ); + } +} + +export const AssemblyViewToggleButton = explicitConnect< + {||}, + StateProps, + DispatchProps +>({ + mapStateToProps: (state) => ({ + assemblyViewIsOpen: getAssemblyViewIsOpen(state), + }), + mapDispatchToProps: { + openAssemblyView, + closeAssemblyView, + }, + component: AssemblyViewToggleButtonImpl, +}); diff --git a/src/components/app/BottomBox.css b/src/components/app/BottomBox.css index 15caca382f..d424daf551 100644 --- a/src/components/app/BottomBox.css +++ b/src/components/app/BottomBox.css @@ -2,14 +2,15 @@ * 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/. */ -.bottom-box { +.bottom-box-pane { display: flex; - height: 100%; - flex-flow: column; + height: 100%; /* direct child of SplitterLayout */ + flex-flow: column nowrap; } -.bottom-main { - position: relative; +.bottom-sourceview-wrapper, +.bottom-assemblyview-wrapper { + position: relative; /* containing block for status overlay */ display: flex; min-height: 0; flex: 1; @@ -17,6 +18,22 @@ background: var(--grey-20); } +.bottom-box .layout-splitter { + position: relative; /* containing block for absolute ::before */ + width: 1px; + border: none; + background-color: var(--grey-30) !important; +} + +/* Provide 3px extra grabbable surface on each side of the splitter */ +.bottom-box .layout-splitter::before { + position: absolute; + z-index: 1; + display: block; + content: ''; + inset: 0 -3px; +} + .bottom-box-bar { display: flex; overflow: hidden; @@ -24,25 +41,47 @@ flex-flow: row; align-items: center; justify-content: space-between; + border-bottom: 1px solid var(--grey-30); background: var(--grey-10); - box-shadow: inset 0 -1px var(--grey-30); line-height: 18px; } .bottom-box-title { - margin: 0; - margin-left: 8px; + overflow: hidden; + margin: 0 8px; font: inherit; + text-overflow: ellipsis; + white-space: nowrap; } -.bottom-close-button { +.bottom-box-header-trailing-buttons { + display: flex; + height: 100%; + flex-flow: row nowrap; + align-items: center; +} + +.bottom-close-button, +.bottom-assembly-button { width: 24px; height: 24px; flex-shrink: 0; - background: url(/res/img/svg/close-dark.svg) no-repeat center / 16px 16px; + background-position: center; + background-repeat: no-repeat; + background-size: 16px 16px; +} + +.bottom-close-button { + background-image: url(/res/img/svg/close-dark.svg); +} + +.bottom-assembly-button { + background-image: url(/res/img/svg/asm-icon.svg); } -.sourceStatusOverlay { +.codeLoadingOverlay, +.sourceCodeErrorOverlay, +.assemblyCodeErrorOverlay { /** * Put the overlay on top of everything in .bottom-main, but centered * horizontally and vertically. We center using margin: auto, and enforce @@ -69,8 +108,8 @@ word-break: break-word; } -/* Use :before to add a loading spinner image */ -.sourceStatusOverlay.loading::before { +/* Use ::before to add a loading spinner image */ +.codeLoadingOverlay::before { display: block; width: 32px; height: 32px; @@ -79,8 +118,9 @@ content: ''; } -/* Use :before to add an alert icon */ -.sourceStatusOverlay.error::before { +/* Use ::before to add an alert icon */ +.sourceCodeErrorOverlay::before, +.assemblyCodeErrorOverlay::before { display: block; width: 50px; height: 50px; @@ -90,6 +130,6 @@ filter: brightness(70%) drop-shadow(0 1px rgb(255 255 255 / 0.5)); } -.sourceStatusOverlay ul { +.codeErrorOverlay { padding-left: 20px; } diff --git a/src/components/app/BottomBox.js b/src/components/app/BottomBox.js index 5ef89658ba..348ecd048c 100644 --- a/src/components/app/BottomBox.js +++ b/src/components/app/BottomBox.js @@ -4,12 +4,20 @@ // @flow import React from 'react'; +import SplitterLayout from 'react-splitter-layout'; import classNames from 'classnames'; import { SourceView } from '../shared/SourceView'; +import { AssemblyView } from '../shared/AssemblyView'; +import { AssemblyViewToggleButton } from './AssemblyViewToggleButton'; +import { CodeLoadingOverlay } from './CodeLoadingOverlay'; +import { CodeErrorOverlay } from './CodeErrorOverlay'; import { getSourceViewFile, getSourceViewScrollGeneration, + getAssemblyViewIsOpen, + getAssemblyViewNativeSymbol, + getAssemblyViewScrollGeneration, } from 'firefox-profiler/selectors/url-state'; import { selectedThreadSelectors, @@ -17,13 +25,24 @@ import { } from 'firefox-profiler/selectors/per-thread'; import { closeBottomBox } from 'firefox-profiler/actions/profile-view'; import { parseFileNameFromSymbolication } from 'firefox-profiler/utils/special-paths'; -import { getSourceViewCode } from 'firefox-profiler/selectors/code'; +import { + getSourceViewCode, + getAssemblyViewCode, +} from 'firefox-profiler/selectors/code'; import { getPreviewSelection } from 'firefox-profiler/selectors/profile'; -import { assertExhaustiveCheck } from 'firefox-profiler/utils/flow'; import explicitConnect from 'firefox-profiler/utils/connect'; import type { ConnectedProps } from 'firefox-profiler/utils/connect'; -import type { LineTimings, SourceCodeStatus } from 'firefox-profiler/types'; +import type { + LineTimings, + AddressTimings, + SourceCodeStatus, + AssemblyCodeStatus, + NativeSymbolInfo, + SourceCodeLoadingError, + ApiQueryError, +} from 'firefox-profiler/types'; +import type { CodeErrorOverlayProps } from './CodeErrorOverlay'; import { Localized } from '@fluent/react'; @@ -32,9 +51,15 @@ import './BottomBox.css'; type StateProps = {| +sourceViewFile: string | null, +sourceViewCode: SourceCodeStatus | void, + +sourceViewScrollGeneration: number, +globalLineTimings: LineTimings, +selectedCallNodeLineTimings: LineTimings, - +sourceViewScrollGeneration: number, + +assemblyViewIsOpen: boolean, + +assemblyViewNativeSymbol: NativeSymbolInfo | null, + +assemblyViewCode: AssemblyCodeStatus | void, + +assemblyViewScrollGeneration: number, + +globalAddressTimings: AddressTimings, + +selectedCallNodeAddressTimings: AddressTimings, +disableOverscan: boolean, |}; @@ -44,191 +69,83 @@ type DispatchProps = {| type Props = ConnectedProps<{||}, StateProps, DispatchProps>; -type SourceStatusOverlayProps = {| status: SourceCodeStatus |}; +export function SourceCodeErrorOverlay({ errors }: CodeErrorOverlayProps) { + return ( + + + + Source code not available + + + ), + }} + > + + See + + issue #3741 + + for supported scenarios and planned improvements. + + + + + + ); +} -function SourceStatusOverlay({ status }: SourceStatusOverlayProps) { - switch (status.type) { - case 'AVAILABLE': - return null; // No overlay if we have source code. - case 'LOADING': { - const { source } = status; - switch (source.type) { - case 'URL': { - const { url } = source; - let host; - try { - host = new URL(url).host; - } catch (e) { - host = url; - } - return ( - - - {`Waiting for ${host}…`} - - - ); - } - case 'BROWSER_CONNECTION': { - return ( - - - Waiting for browser… - - - ); - } - default: - throw assertExhaustiveCheck(source.type); - } - } - case 'ERROR': { - return ( - - - - Source not available - - - ), - }} +export function AssemblyCodeErrorOverlay({ errors }: CodeErrorOverlayProps) { + return ( + + + + Assembly code not available + + + ), + }} + > + + See + - - See - - issue #3741 - - for supported scenarios and planned improvements. - - - - {status.errors.map((error, key) => { - switch (error.type) { - case 'NO_KNOWN_CORS_URL': { - return ( - - No known cross-origin-accessible URL. - - ); - } - case 'NETWORK_ERROR': { - const { url, networkErrorMessage } = error; - return ( - - {`There was a network error when fetching the URL ${url}: ${networkErrorMessage}`} - - ); - } - case 'BROWSER_CONNECTION_ERROR': { - const { browserConnectionErrorMessage } = error; - return ( - - {`Could not query the browser’s symbolication API: ${browserConnectionErrorMessage}`} - - ); - } - case 'BROWSER_API_ERROR': { - const { apiErrorMessage } = error; - return ( - - {`The browser’s symbolication API returned an error: ${apiErrorMessage}`} - - ); - } - case 'SYMBOL_SERVER_API_ERROR': { - const { apiErrorMessage } = error; - return ( - - {`The local symbol server’s symbolication API returned an error: ${apiErrorMessage}`} - - ); - } - case 'BROWSER_API_MALFORMED_RESPONSE': { - const { errorMessage } = error; - return ( - - {`The browser’s symbolication API returned a malformed response: ${errorMessage}`} - - ); - } - case 'SYMBOL_SERVER_API_MALFORMED_RESPONSE': { - const { errorMessage } = error; - return ( - - {`The local symbol server’s symbolication API returned a malformed response: ${errorMessage}`} - - ); - } - case 'NOT_PRESENT_IN_ARCHIVE': { - const { url, pathInArchive } = error; - return ( - - {`The file ${pathInArchive} was not found in the archive from ${url}.`} - - ); - } - case 'ARCHIVE_PARSING_ERROR': { - const { url, parsingErrorMessage } = error; - return ( - - {`The archive at ${url} could not be parsed: ${parsingErrorMessage}`} - - ); - } - default: - throw assertExhaustiveCheck(error.type); - } - })} - - - - ); - } - default: - throw assertExhaustiveCheck(status.type); - } + issue #4520 + + for supported scenarios and planned improvements. + + + + + + ); } class BottomBoxImpl extends React.PureComponent { _sourceView = React.createRef(); + _assemblyView = React.createRef(); _onClickCloseButton = () => { this.props.closeBottomBox(); @@ -242,54 +159,127 @@ class BottomBoxImpl extends React.PureComponent { disableOverscan, sourceViewScrollGeneration, selectedCallNodeLineTimings, + assemblyViewIsOpen, + assemblyViewScrollGeneration, + assemblyViewNativeSymbol, + assemblyViewCode, + globalAddressTimings, + selectedCallNodeAddressTimings, } = this.props; - const source = + const sourceCode = sourceViewCode && sourceViewCode.type === 'AVAILABLE' - ? sourceViewCode.source + ? sourceViewCode.code : ''; const path = sourceViewFile !== null ? parseFileNameFromSymbolication(sourceViewFile).path : null; + const assemblyCode = + assemblyViewCode && assemblyViewCode.type === 'AVAILABLE' + ? assemblyViewCode.instructions + : []; + + // The bottom box has one or more side-by-side panes. + // At the moment it always has either one or two panes: + // - It always has the source view pane + // - It also has the assembly view pane, if the assembly view is open. + + // These trailing header buttons go into the bottom-box-bar of the last pane. + const trailingHeaderButtons = ( + + + + + + + ); + return ( - - {path ?? '(no file selected)'} - - - - - - {sourceViewFile !== null ? ( - - ) : null} - {sourceViewCode !== undefined && - sourceViewCode.type !== 'AVAILABLE' ? ( - + + + + {path ?? '(no source file)'} + {assemblyViewIsOpen ? null : trailingHeaderButtons} + + + {sourceViewFile !== null ? ( + + ) : null} + {sourceViewCode !== undefined && + sourceViewCode.type === 'LOADING' ? ( + + ) : null} + {sourceViewCode !== undefined && + sourceViewCode.type === 'ERROR' ? ( + + ) : null} + + + + {assemblyViewIsOpen ? ( + + + + {assemblyViewNativeSymbol !== null + ? assemblyViewNativeSymbol.name + : '(no native symbol)'} + + {trailingHeaderButtons} + + + {assemblyViewNativeSymbol !== null ? ( + + ) : null} + {assemblyViewCode !== undefined && + assemblyViewCode.type === 'LOADING' ? ( + + ) : null} + {assemblyViewCode !== undefined && + assemblyViewCode.type === 'ERROR' ? ( + + ) : null} + + ) : null} - + ); } } +function convertErrors(errors: ApiQueryError[]): SourceCodeLoadingError[] { + // Copy the array so that the types work out. + return errors.map((e) => e); +} + export const BottomBox = explicitConnect<{||}, StateProps, DispatchProps>({ mapStateToProps: (state) => ({ sourceViewFile: getSourceViewFile(state), @@ -298,6 +288,14 @@ export const BottomBox = explicitConnect<{||}, StateProps, DispatchProps>({ selectedCallNodeLineTimings: selectedNodeSelectors.getSourceViewLineTimings(state), sourceViewScrollGeneration: getSourceViewScrollGeneration(state), + assemblyViewNativeSymbol: getAssemblyViewNativeSymbol(state), + assemblyViewCode: getAssemblyViewCode(state), + globalAddressTimings: + selectedThreadSelectors.getAssemblyViewAddressTimings(state), + selectedCallNodeAddressTimings: + selectedNodeSelectors.getAssemblyViewAddressTimings(state), + assemblyViewScrollGeneration: getAssemblyViewScrollGeneration(state), + assemblyViewIsOpen: getAssemblyViewIsOpen(state), disableOverscan: getPreviewSelection(state).isModifying, }), mapDispatchToProps: { diff --git a/src/components/app/CodeErrorOverlay.js b/src/components/app/CodeErrorOverlay.js new file mode 100644 index 0000000000..f833fd19c6 --- /dev/null +++ b/src/components/app/CodeErrorOverlay.js @@ -0,0 +1,124 @@ +/* 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/. */ +// @flow + +import React from 'react'; + +import { assertExhaustiveCheck } from 'firefox-profiler/utils/flow'; +import type { SourceCodeLoadingError } from 'firefox-profiler/types'; +import { Localized } from '@fluent/react'; + +export type CodeErrorOverlayProps = {| + errors: SourceCodeLoadingError[], +|}; + +export function CodeErrorOverlay({ errors }: CodeErrorOverlayProps) { + return ( + + {errors.map((error, key) => { + switch (error.type) { + case 'NO_KNOWN_CORS_URL': { + return ( + + No known cross-origin-accessible URL. + + ); + } + case 'NETWORK_ERROR': { + const { url, networkErrorMessage } = error; + return ( + + {`There was a network error when fetching the URL ${url}: ${networkErrorMessage}`} + + ); + } + case 'BROWSER_CONNECTION_ERROR': { + const { browserConnectionErrorMessage } = error; + return ( + + {`Could not query the browser’s symbolication API: ${browserConnectionErrorMessage}`} + + ); + } + case 'BROWSER_API_ERROR': { + const { apiErrorMessage } = error; + return ( + + {`The browser’s symbolication API returned an error: ${apiErrorMessage}`} + + ); + } + case 'SYMBOL_SERVER_API_ERROR': { + const { apiErrorMessage } = error; + return ( + + {`The local symbol server’s symbolication API returned an error: ${apiErrorMessage}`} + + ); + } + case 'BROWSER_API_MALFORMED_RESPONSE': { + const { errorMessage } = error; + return ( + + {`The browser’s symbolication API returned a malformed response: ${errorMessage}`} + + ); + } + case 'SYMBOL_SERVER_API_MALFORMED_RESPONSE': { + const { errorMessage } = error; + return ( + + {`The local symbol server’s symbolication API returned a malformed response: ${errorMessage}`} + + ); + } + case 'NOT_PRESENT_IN_ARCHIVE': { + const { url, pathInArchive } = error; + return ( + + {`The file ${pathInArchive} was not found in the archive from ${url}.`} + + ); + } + case 'ARCHIVE_PARSING_ERROR': { + const { url, parsingErrorMessage } = error; + return ( + + {`The archive at ${url} could not be parsed: ${parsingErrorMessage}`} + + ); + } + default: + throw assertExhaustiveCheck(error.type); + } + })} + + ); +} diff --git a/src/components/app/CodeLoadingOverlay.js b/src/components/app/CodeLoadingOverlay.js new file mode 100644 index 0000000000..8857e7f3cc --- /dev/null +++ b/src/components/app/CodeLoadingOverlay.js @@ -0,0 +1,44 @@ +/* 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/. */ +// @flow + +import React from 'react'; + +import { assertExhaustiveCheck } from 'firefox-profiler/utils/flow'; +import { Localized } from '@fluent/react'; +import type { CodeLoadingSource } from 'firefox-profiler/types'; + +type CodeLoadingOverlayProps = {| + source: CodeLoadingSource, +|}; + +export function CodeLoadingOverlay({ source }: CodeLoadingOverlayProps) { + switch (source.type) { + case 'URL': { + const { url } = source; + let host; + try { + host = new URL(url).host; + } catch (e) { + host = url; + } + return ( + + {`Waiting for ${host}…`} + + ); + } + case 'BROWSER_CONNECTION': { + return ( + + Waiting for browser… + + ); + } + default: + throw assertExhaustiveCheck(source.type); + } +} + +export default CodeLoadingOverlay; diff --git a/src/components/shared/AssemblyView-codemirror.js b/src/components/shared/AssemblyView-codemirror.js new file mode 100644 index 0000000000..e53cc956a6 --- /dev/null +++ b/src/components/shared/AssemblyView-codemirror.js @@ -0,0 +1,287 @@ +/* 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/. */ +// @flow + +/** + * This module wraps all the interaction with the CodeMirror API into a + * AssemblyViewEditor class. + * + * This module is intended to be imported asynchronously, so that all the + * CodeMirror code can be split into a separate bundle chunk. + * + * This file implements the following features: + * - Display assembly code. + * - Display a gutter with: + * - "Total" timings for each instruction + * - "Self" timings for each instruction + * - The address for each instruction + * - Highlight assembly code lines which have a non-zero timing, by applying + * a cm-nonZeroLine class to them. This highlight line goes across the entire + * width of the editor, it covers both the gutter and the main area. + */ +import { EditorView, gutter } from '@codemirror/view'; +import { EditorState, StateField, StateEffect } from '@codemirror/state'; +import { syntaxHighlighting } from '@codemirror/language'; +import { classHighlighter } from '@lezer/highlight'; +import clamp from 'clamp'; + +import type { + AddressTimings, + Address, + LineTimings, + LineNumber, + NativeSymbolInfo, + DecodedInstruction, +} from 'firefox-profiler/types'; + +import { bisectionRight } from 'firefox-profiler/utils/bisect'; +import { + timingsExtension, + updateTimingsEffect, + StringMarker, +} from 'firefox-profiler/utils/codemirror-shared'; + +// An "effect" is like a redux action. This effect is used to replace the value +// of the state field addressToLineMapField. +const updateAddressToLineMapEffect = StateEffect.define(); + +// This "state field" stores the current AddressToLineMap. This field allows the +// instructionAddressGutter to map line numbers to addresses. +const addressToLineMapField = StateField.define({ + create() { + return []; + }, + update(instructionAddresses, transaction) { + // Get the new value from an effect in the transaction. + let newSortedAddresses = instructionAddresses; + for (const effect of transaction.effects) { + if (effect.is(updateAddressToLineMapEffect)) { + newSortedAddresses = effect.value; + } + } + return newSortedAddresses; + }, +}); + +// A gutter which displays the address of each instruction. +const instructionAddressGutter = gutter({ + class: 'cm-instruction-address-gutter', + + // Returns a gutter marker for this line, or null. + lineMarker(view, line) { + const lineNumber = view.state.doc.lineAt(line.from).number; + const map = view.state.field(addressToLineMapField); + const address = map.lineToAddress(lineNumber); + return address !== null + ? new StringMarker(`0x${address.toString(16)}`) + : null; + }, + + // Returns true if the update affects the instruction addresses in the gutter. + lineMarkerChange(update) { + return update.transactions.some((t) => + t.effects.some((e) => e.is(updateAddressToLineMapEffect)) + ); + }, +}); + +function instructionsToText(assemblyCode: DecodedInstruction[]): string { + return assemblyCode.map((instr) => instr.decodedString).join('\n'); +} + +/** + * This map is used to convert between instruction addresses and editor line + * numbers. + */ +class AddressToLineMap { + // The address of each instruction. This stays constant for the entire lifetime + // of this AddressToLineMap instance. + // + // _instructionAddresses[0] contains the address of the instruction which is + // displayed in line 1. (Line numbers are 1-based.) + // + // The addresses need to be ordered from low to high, so that the binary search + // works. + _instructionAddresses: Address[]; + + constructor(instructionAddresses) { + this._instructionAddresses = instructionAddresses; + } + + // Find the line which displays the instruction which covers `address`. + // `address` doesn't need to be a perfect match for the instruction address; + // for example, in the example below, address 0x10e4 is mapped to line 3: + // + // 1: 0x10da: mov r14, rdi + // 2: 0x10dd: mov rdi, rsi + // 3: 0x10e0: call _malloc_usable_size + // 4: 0x10e5: test rax, rax + // 5: 0x10e8: je loc_10f6 + addressToLine(address: Address): LineNumber | null { + const insertionIndex = bisectionRight(this._instructionAddresses, address); + if (insertionIndex === 0) { + // address < instructionAddresses[0] + return null; + } + + const elementIndex = insertionIndex - 1; + const lineNumber = elementIndex + 1; + return lineNumber; + } + + // Return the address of the instruction which is displayed in line `lineNumber`. + lineToAddress(lineNumber: LineNumber): Address | null { + if (lineNumber < 1 || lineNumber > this._instructionAddresses.length) { + return null; + } + + const elementIndex = lineNumber - 1; + return this._instructionAddresses[elementIndex]; + } +} + +function getInstructionAddresses( + assemblyCode: DecodedInstruction[] +): Address[] { + return assemblyCode.map((instr) => instr.address); +} + +// Convert AddressTimings to LineTimings with the help of an AddressToLineMap. +function addressTimingsToLineTimings( + addressTimings: AddressTimings, + map: AddressToLineMap +): LineTimings { + const totalLineHits = new Map(); + for (const [address, hitCount] of addressTimings.totalAddressHits) { + const line = map.addressToLine(address); + if (line !== null) { + const currentHitCount = totalLineHits.get(line) ?? 0; + totalLineHits.set(line, currentHitCount + hitCount); + } + } + + const selfLineHits = new Map(); + for (const [address, hitCount] of addressTimings.selfAddressHits) { + const line = map.addressToLine(address); + if (line !== null) { + const currentHitCount = selfLineHits.get(line) ?? 0; + selfLineHits.set(line, currentHitCount + hitCount); + } + } + + return { totalLineHits, selfLineHits }; +} + +export class AssemblyViewEditor { + _view: EditorView; + _addressToLineMap: AddressToLineMap; + _addressTimings: AddressTimings; + + // Create a CodeMirror editor and add it as a child element of domParent. + constructor( + initialAssemblyCode: DecodedInstruction[], + nativeSymbol: NativeSymbolInfo, + addressTimings: AddressTimings, + domParent: Element + ) { + this._addressToLineMap = new AddressToLineMap( + getInstructionAddresses(initialAssemblyCode) + ); + this._addressTimings = addressTimings; + let state = EditorState.create({ + doc: instructionsToText(initialAssemblyCode), + extensions: [ + timingsExtension, + addressToLineMapField, + instructionAddressGutter, + syntaxHighlighting(classHighlighter), + EditorState.readOnly.of(true), + EditorView.editable.of(false), + ], + }); + const lineTimings = addressTimingsToLineTimings( + this._addressTimings, + this._addressToLineMap + ); + state = state.update({ + effects: [ + updateAddressToLineMapEffect.of(this._addressToLineMap), + updateTimingsEffect.of(lineTimings), + ], + }).state; + this._view = new EditorView({ + state, + parent: domParent, + }); + } + + setContents(assemblyCode: DecodedInstruction[]) { + this._addressToLineMap = new AddressToLineMap( + getInstructionAddresses(assemblyCode) + ); + const lineTimings = addressTimingsToLineTimings( + this._addressTimings, + this._addressToLineMap + ); + // The CodeMirror way of replacing the entire contents is to insert new text + // and overwrite the full range of existing text. + const text = instructionsToText(assemblyCode); + this._view.dispatch( + this._view.state.update({ + changes: { + insert: text, + from: 0, + to: this._view.state.doc.length, + }, + }) + ); + this._view.dispatch({ + effects: [ + updateAddressToLineMapEffect.of(this._addressToLineMap), + updateTimingsEffect.of(lineTimings), + ], + }); + } + + setTimings(addressTimings: AddressTimings) { + // Update the value of the timings field by dispatching an updateTimingsEffect. + this._addressTimings = addressTimings; + const lineTimings = addressTimingsToLineTimings( + this._addressTimings, + this._addressToLineMap + ); + this._view.dispatch({ + effects: updateTimingsEffect.of(lineTimings), + }); + } + + scrollToLine(lineNumber: number) { + // Clamp the line number to the document's line count. + lineNumber = clamp(lineNumber, 1, this._view.state.doc.lines); + + // Convert the line number into a position. + const pos = this._view.state.doc.line(lineNumber).from; + // Dispatch the scroll action. + this._view.dispatch({ + effects: EditorView.scrollIntoView(pos, { y: 'start', yMargin: 0 }), + }); + // Trigger a measure flush, to work around + // https://github.com/codemirror/codemirror.next/issues/676 + this._view.coordsAtPos(0); + } + + scrollToAddress(address: Address) { + const lineNumber = this._addressToLineMap.addressToLine(address); + if (lineNumber !== null) { + this.scrollToLine(lineNumber); + } + } + + scrollToAddressWithSpaceOnTop(address: Address, topSpaceLines: number) { + const lineNumber = this._addressToLineMap.addressToLine(address); + if (lineNumber !== null) { + this.scrollToLine(lineNumber - topSpaceLines); + } + } +} diff --git a/src/components/shared/AssemblyView.js b/src/components/shared/AssemblyView.js new file mode 100644 index 0000000000..5018671217 --- /dev/null +++ b/src/components/shared/AssemblyView.js @@ -0,0 +1,193 @@ +/* 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/. */ +// @flow + +import * as React from 'react'; + +import { ensureExists } from 'firefox-profiler/utils/flow'; +import type { + AddressTimings, + NativeSymbolInfo, + DecodedInstruction, +} from 'firefox-profiler/types'; +import { mapGetKeyWithMaxValue } from 'firefox-profiler/utils'; + +import type { AssemblyViewEditor } from './AssemblyView-codemirror'; + +import './CodeView.css'; + +const AssemblyViewHeader = () => { + return ( + + + Total + + + Self + + + + ); +}; + +type AssemblyViewProps = {| + +timings: AddressTimings, + +assemblyCode: DecodedInstruction[], + +disableOverscan: boolean, + +nativeSymbol: NativeSymbolInfo | null, + +scrollToHotSpotGeneration: number, + +hotSpotTimings: AddressTimings, +|}; + +let editorModulePromise: Promise | null = null; + +export class AssemblyView extends React.PureComponent { + _ref = React.createRef(); + _editor: AssemblyViewEditor | null = null; + + /** + * Scroll to the line with the most hits, based on the timings in + * timingsForScrolling. + * + * How is timingsForScrolling different from this.props.timings? + * In the current implementation, this.props.timings are always the "global" + * timings, i.e. they show the line hits for all samples in the current view, + * regardless of the selected call node. However, when opening the assembly + * view from a specific call node, you really want to see the code that's + * relevant to that specific call node, or at least that specific function. + * So timingsForScrolling are the timings that indicate just the line hits + * in the selected call node. This means that the "hotspot" will be somewhere + * in the selected function, and it will even be in the line that's most + * relevant to that specific call node. + * + * Sometimes, timingsForScrolling can be completely empty. This happens, for + * example, when the assembly view is showing a different file than the + * selected call node's function's file, for example because we just loaded + * from a URL and ended up with an arbitrary selected call node. + * In that case, pick the hotspot from the global line timings. + */ + _scrollToHotSpot(timingsForScrolling: AddressTimings) { + const heaviestAddress = + mapGetKeyWithMaxValue(timingsForScrolling.totalAddressHits) ?? + mapGetKeyWithMaxValue(this.props.timings.totalAddressHits); + if (heaviestAddress !== undefined) { + this._scrollToAddressWithSpaceOnTop(heaviestAddress, 5); + } + } + + _scrollToAddressWithSpaceOnTop(address: number, topSpaceLines: number) { + if (this._editor) { + this._editor.scrollToAddressWithSpaceOnTop(address, topSpaceLines); + } + } + + _getAssemblyCodeOrFallback(): DecodedInstruction[] { + const { assemblyCode } = this.props; + if (assemblyCode.length !== 0) { + // We have assembly code for the selected symbol. Good. + return assemblyCode; + } + + // We don't have the true assembly code yet, and don't really know + // at which address each instruction starts. + // Compute a fallback by getting known addresses from the timings. + + const { timings, nativeSymbol } = this.props; + const addresses = [...timings.totalAddressHits.keys()]; + + // Also include the start address of the symbol, if it's not already present. + if ( + nativeSymbol !== null && + !timings.totalAddressHits.has(nativeSymbol.address) + ) { + addresses.push(nativeSymbol.address); + } + + addresses.sort((a, b) => a - b); + + // Create fallback assembly code where each known address is mapped to an + // empty string instruction. + return addresses.map((address) => ({ + address, + decodedString: '', + })); + } + + render() { + return ( + + + + + ); + } + + componentDidMount() { + // Load the module with all the @codemirror imports asynchronously, so that + // it can be split into a separate bundle chunk. + if (editorModulePromise === null) { + editorModulePromise = import('./AssemblyView-codemirror'); + } + (async () => { + const codeMirrorModulePromise = ensureExists(editorModulePromise); + const codeMirrorModule = await codeMirrorModulePromise; + const domParent = this._ref.current; + if (!domParent) { + return; + } + const { AssemblyViewEditor } = codeMirrorModule; + const editor = new AssemblyViewEditor( + this._getAssemblyCodeOrFallback(), + this.props.nativeSymbol, + this.props.timings, + domParent + ); + this._editor = editor; + this._scrollToHotSpot(this.props.hotSpotTimings); + })(); + } + + // CodeMirror's API is not based on React. When our props change, we need to + // translate those changes into CodeMirror API calls manually. + componentDidUpdate(prevProps: AssemblyViewProps) { + if (!this._editor) { + return; + } + + let contentsChanged = false; + if ( + this.props.assemblyCode !== prevProps.assemblyCode || + (this.props.assemblyCode.length === 0 && + prevProps.assemblyCode.length === 0 && + this.props.timings !== prevProps.timings) + ) { + this._editor.setContents(this._getAssemblyCodeOrFallback()); + contentsChanged = true; + } + + if ( + contentsChanged || + this.props.scrollToHotSpotGeneration !== + prevProps.scrollToHotSpotGeneration + ) { + this._scrollToHotSpot(this.props.hotSpotTimings); + } + + if (this.props.timings !== prevProps.timings) { + this._editor.setTimings(this.props.timings); + } + } +} diff --git a/src/components/shared/SourceView.css b/src/components/shared/CodeView.css similarity index 79% rename from src/components/shared/SourceView.css rename to src/components/shared/CodeView.css index c9edff7266..17bb1813a4 100644 --- a/src/components/shared/SourceView.css +++ b/src/components/shared/CodeView.css @@ -2,7 +2,7 @@ * 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/. */ -.sourceView { +.codeView { display: flex; min-height: 0; flex: 1; @@ -24,7 +24,7 @@ --theme-highlight-punctuation: var(--grey-70); } -.sourceViewHeader { +.codeViewHeader { display: flex; height: 16px; flex-flow: row; @@ -55,11 +55,11 @@ background-color: white !important; } -.sourceViewHeaderMainColumn { +.codeViewHeaderMainColumn { flex: 1; } -.sourceViewHeaderColumn { +.codeViewHeaderColumn { position: relative; box-sizing: border-box; padding: 1px 5px; @@ -68,7 +68,7 @@ } /* A small vertical separator line */ -.sourceViewHeaderColumn.sourceViewFixedColumn::after { +.codeViewHeaderColumn.codeViewFixedColumn::after { position: absolute; top: 3px; right: 0; @@ -78,8 +78,8 @@ content: ''; } -.sourceViewHeaderColumn.total, -.sourceViewHeaderColumn.self, +.codeViewHeaderColumn.total, +.codeViewHeaderColumn.self, .cm-total-timings-gutter, .cm-self-timings-gutter { width: 50px; @@ -101,6 +101,17 @@ min-width: 45px !important; } +.cm-instruction-address-gutter { + color: #aaa; + font-family: ui-monospace, 'Roboto Mono', monospace; + font-variant-numeric: tabular-nums; +} + +.cm-instruction-address-gutter .cm-gutterElement { + min-width: 45px !important; + padding: 0 8px; +} + .cm-total-timings-gutter .cm-gutterElement, .cm-self-timings-gutter .cm-gutterElement { overflow: hidden; @@ -110,57 +121,7 @@ text-overflow: ellipsis; } -.sourceViewBody { - position: relative; - z-index: 0; - overflow: auto; - flex: 1; - line-height: 16px; - outline: 0; - will-change: scroll-position; -} - -.sourceViewBodyInnerWrapper { - position: absolute; - top: 0; - left: 0; - display: flex; - min-width: 100%; - flex-flow: row; -} - -.sourceViewBodyInner0 { - position: sticky; - z-index: 2; - left: 0; -} - -.sourceViewBodyInner1 { - /* This allows the right column to expand more than its content, so that the - * background always extends to the right edge. */ - flex-grow: 1; -} - -.sourceViewBodyInner { - overflow: hidden; - background: white; -} - -.sourceViewRowFixedColumns { - display: flex; - flex-flow: row; - align-items: stretch; - justify-content: flex-start; - white-space: nowrap; -} - -.sourceViewRowScrolledColumns { - padding-left: 3px; - white-space: pre; -} - -.cm-nonZeroLine, -.sourceViewRowNonZero { +.cm-nonZeroLine { background-color: #edf6ff; } diff --git a/src/components/shared/SourceView-codemirror.js b/src/components/shared/SourceView-codemirror.js index 1c76e95fd3..fe7a2bf47d 100644 --- a/src/components/shared/SourceView-codemirror.js +++ b/src/components/shared/SourceView-codemirror.js @@ -20,21 +20,8 @@ * a cm-nonZeroLine class to them. This highlight line goes across the entire * width of the editor, it covers both the gutter and the main area. */ -import { - EditorView, - Decoration, - lineNumbers, - GutterMarker, - gutter, - gutterLineClass, -} from '@codemirror/view'; -import { - EditorState, - StateField, - StateEffect, - Compartment, - RangeSet, -} from '@codemirror/state'; +import { EditorView, lineNumbers } from '@codemirror/view'; +import { EditorState, Compartment } from '@codemirror/state'; import { syntaxHighlighting } from '@codemirror/language'; import { classHighlighter } from '@lezer/highlight'; import { cpp } from '@codemirror/lang-cpp'; @@ -43,7 +30,10 @@ import { javascript } from '@codemirror/lang-javascript'; import clamp from 'clamp'; import type { LineTimings } from 'firefox-profiler/types'; -import { emptyLineTimings } from 'firefox-profiler/profile-logic/line-timings'; +import { + timingsExtension, + updateTimingsEffect, +} from 'firefox-profiler/utils/codemirror-shared'; // This "compartment" allows us to swap the syntax highlighting language when // the file path changes. @@ -84,148 +74,6 @@ function _languageExtForPath( return []; } -// This gutter marker applies the "cm-nonZeroLine" class to gutter elements. -const nonZeroLineGutterMarker = new (class extends GutterMarker { - elementClass = 'cm-nonZeroLine'; -})(); - -// This "decoration" applies the "cm-nonZeroLine" class to the line of source -// code in the main editor contents (not the gutter). -const nonZeroLineDecoration = Decoration.line({ class: 'cm-nonZeroLine' }); - -// An "effect" is like a redux action. This effect is used to replace the value -// of the timingsField state field. -const updateTimingsEffect = StateEffect.define(); - -// A "state field" for the timings. -const timingsField = StateField.define({ - create() { - return emptyLineTimings; - }, - update(timings, transaction) { - // This is like a reducer. Find an updateTimingsEffect in the transaction - // and set this field to the timings in it. - let newTimings = timings; - for (const effect of transaction.effects) { - if (effect.is(updateTimingsEffect)) { - newTimings = effect.value; - } - } - return newTimings; - }, -}); - -// Finds all lines with non-zero line timings, for the highlight line. -// The line numbers are then converted into "positions", i.e. character offsets -// in the document, for the start of the line. -// Then they are sorted, because our caller wants to have a sorted list. -function getSortedStartPositionsOfNonZeroLines(state: EditorState): number[] { - const timings = state.field(timingsField); - const nonZeroLines = new Set(); - for (const lineNumber of timings.totalLineHits.keys()) { - nonZeroLines.add(lineNumber); - } - for (const lineNumber of timings.selfLineHits.keys()) { - nonZeroLines.add(lineNumber); - } - const lineCount = state.doc.lines; - const positions = [...nonZeroLines] - .filter((l) => l <= lineCount) - .map((lineNumber) => state.doc.line(lineNumber).from); - positions.sort((a, b) => a - b); - return positions; -} - -// This is an "extension" which applies the "cm-nonZeroLine" class to all gutter -// elements for lines with non-zero timings. It is like a piece of derived state; -// it needs to be recomputed whenever one of the input states change. The input -// states are the editor contents ("doc") and the value of the timings field. -// The editor contents are relevant because the output is expressed in terms of -// positions, i.e. character offsets from the document start, and those positions -// need to be updated if the amount of text in a line changes. This happens when -// we replace the file placeholder content with the actual file content. -const nonZeroLineGutterHighlighter = gutterLineClass.compute( - ['doc', timingsField], - (state) => { - const positions = getSortedStartPositionsOfNonZeroLines(state); - return RangeSet.of(positions.map((p) => nonZeroLineGutterMarker.range(p))); - } -); - -// Same as the previous extension, but this one is for the main editor. There -// doesn't seem to be a way to set a class for the entire line, i.e. both the -// gutter elements and the main editor elements of that line. -const nonZeroLineDecorationHighlighter = EditorView.decorations.compute( - ['doc', timingsField], - (state) => { - const positions = getSortedStartPositionsOfNonZeroLines(state); - return RangeSet.of(positions.map((p) => nonZeroLineDecoration.range(p))); - } -); - -// This is a "gutter marker" which renders just a string and nothing else. -// It is used for the LineTimings annotations, i.e. for the numbers in the -// gutter. -class StringMarker extends GutterMarker { - _s; - - constructor(s) { - super(); - this._s = s; - } - - toDOM() { - return document.createTextNode(this._s); - } -} - -// The "extension" which manages the elements in the gutter for the "total" -// column. -const totalTimingsGutter = gutter({ - class: 'cm-total-timings-gutter', - lineMarker(view, line) { - // Return a gutter marker for this line, or null. - const lineNumber = view.state.doc.lineAt(line.from).number; - const timings = view.state.field(timingsField); - const totalTime = timings.totalLineHits.get(lineNumber); - return totalTime !== undefined ? new StringMarker(totalTime) : null; - }, - lineMarkerChange(update) { - // Return true if the update affects the total timings in the gutter. - return update.transactions.some((t) => - t.effects.some((e) => e.is(updateTimingsEffect)) - ); - }, -}); - -// The "extension" which manages the elements in the gutter for the "self" -// column. -const selfTimingsGutter = gutter({ - class: 'cm-self-timings-gutter', - lineMarker(view, line) { - // Return a gutter marker for this line, or null. - const lineNumber = view.state.doc.lineAt(line.from).number; - const timings = view.state.field(timingsField); - const selfTime = timings.selfLineHits.get(lineNumber); - return selfTime !== undefined ? new StringMarker(selfTime) : null; - }, - lineMarkerChange(update) { - // Return true if the update affects the self timings in the gutter. - return update.transactions.some((t) => - t.effects.some((e) => e.is(updateTimingsEffect)) - ); - }, -}); - -// All extensions which have to do with timings, grouped into one extension. -const timingsExtension = [ - timingsField, - totalTimingsGutter, - selfTimingsGutter, - nonZeroLineGutterHighlighter, - nonZeroLineDecorationHighlighter, -]; - // Adjustments to make a CodeMirror editor work as a non-editable code viewer. const codeViewerExtension = [ // Make the editor non-editable. diff --git a/src/components/shared/SourceView.js b/src/components/shared/SourceView.js index 1d72b6c1fd..cc4bee80cf 100644 --- a/src/components/shared/SourceView.js +++ b/src/components/shared/SourceView.js @@ -6,17 +6,18 @@ import * as React from 'react'; import { ensureExists } from 'firefox-profiler/utils/flow'; +import { mapGetKeyWithMaxValue } from 'firefox-profiler/utils'; import type { LineTimings } from 'firefox-profiler/types'; import type { SourceViewEditor } from './SourceView-codemirror'; -import './SourceView.css'; +import './CodeView.css'; const SourceViewHeader = () => { return ( - + Self - + ); }; type SourceViewProps = {| +timings: LineTimings, - +source: string, + +sourceCode: string, +disableOverscan: boolean, +filePath: string | null, +scrollToHotSpotGeneration: number, +hotSpotTimings: LineTimings, |}; -function _mapGetKeyWithMaxValue(map: Map): K | void { - let maxValue = -Infinity; - let keyForMaxValue; - for (const [key, value] of map) { - if (value > maxValue) { - maxValue = value; - keyForMaxValue = key; - } - } - return keyForMaxValue; -} - let editorModulePromise: Promise | null = null; export class SourceView extends React.PureComponent { @@ -88,8 +77,8 @@ export class SourceView extends React.PureComponent { */ _scrollToHotSpot(timingsForScrolling: LineTimings) { const heaviestLine = - _mapGetKeyWithMaxValue(timingsForScrolling.totalLineHits) ?? - _mapGetKeyWithMaxValue(this.props.timings.totalLineHits); + mapGetKeyWithMaxValue(timingsForScrolling.totalLineHits) ?? + mapGetKeyWithMaxValue(this.props.timings.totalLineHits); if (heaviestLine !== undefined) { this._scrollToLine(heaviestLine - 5); } @@ -102,8 +91,8 @@ export class SourceView extends React.PureComponent { } _getMaxLineNumber() { - const { source, timings } = this.props; - const sourceLines = source.split('\n'); + const { sourceCode, timings } = this.props; + const sourceLines = sourceCode.split('\n'); let maxLineNumber = sourceLines.length; if (maxLineNumber <= 1) { // We probably don't have the true source code yet, and don't really know @@ -118,17 +107,17 @@ export class SourceView extends React.PureComponent { return maxLineNumber; } - _getSourceOrFallback() { - const { source } = this.props; - if (source !== '') { - return source; + _getSourceCodeOrFallback() { + const { sourceCode } = this.props; + if (sourceCode !== '') { + return sourceCode; } return '\n'.repeat(this._getMaxLineNumber()); } render() { return ( - + @@ -150,7 +139,7 @@ export class SourceView extends React.PureComponent { } const { SourceViewEditor } = codeMirrorModule; const editor = new SourceViewEditor( - this._getSourceOrFallback(), + this._getSourceCodeOrFallback(), this.props.filePath, this.props.timings, domParent @@ -172,12 +161,12 @@ export class SourceView extends React.PureComponent { } if ( - this.props.source !== prevProps.source || - (this.props.source === '' && - prevProps.source === '' && + this.props.sourceCode !== prevProps.sourceCode || + (this.props.sourceCode === '' && + prevProps.sourceCode === '' && this.props.timings !== prevProps.timings) ) { - this._editor.setContents(this._getSourceOrFallback()); + this._editor.setContents(this._getSourceCodeOrFallback()); } if ( diff --git a/src/reducers/code.js b/src/reducers/code.js index 4aff3d86e7..2bf31b39f7 100644 --- a/src/reducers/code.js +++ b/src/reducers/code.js @@ -32,9 +32,9 @@ const sourceCodeCache: Reducer> = ( return newState; } case 'SOURCE_CODE_LOADING_SUCCESS': { - const { file, source } = action; + const { file, code } = action; const newState = new Map(state); - newState.set(file, { type: 'AVAILABLE', source }); + newState.set(file, { type: 'AVAILABLE', code }); return newState; } case 'SOURCE_CODE_LOADING_ERROR': { diff --git a/src/test/components/SourceView.test.js b/src/test/components/BottomBox.test.js similarity index 57% rename from src/test/components/SourceView.test.js rename to src/test/components/BottomBox.test.js index cc9c337bc1..fb28fbc87b 100644 --- a/src/test/components/SourceView.test.js +++ b/src/test/components/BottomBox.test.js @@ -29,7 +29,7 @@ jest.mock('../../components/timeline', () => ({ Timeline: 'custom-timeline', })); -describe('SourceView', () => { +describe('BottomBox', () => { autoMockDomRect(); afterEach(() => { delete Range.prototype.getClientRects; @@ -50,9 +50,11 @@ describe('SourceView', () => { const revision = '997f00815e6bc28806b75448c8829f0259d2cb28'; const filepath = 'widget/cocoa/nsAppShell.mm'; - window.fetch.get( - `https://hg.mozilla.org/mozilla-central/raw-file/${revision}/${filepath}`, - stripIndent` + window.fetch + .post('http://127.0.0.1:8000/source/v1', 500) + .get( + `https://hg.mozilla.org/mozilla-central/raw-file/${revision}/${filepath}`, + stripIndent` line 1 line 2 line 3 @@ -61,10 +63,30 @@ describe('SourceView', () => { line 6 line 7 ` - ); + ) + .post( + 'http://127.0.0.1:8000/asm/v1', + JSON.stringify({ + startAddress: '0x20', + size: '0x1a', + arch: 'x86_64', + syntax: ['Intel'], + instructions: [ + [0, 'push rsi'], + [1, 'push rdi'], + [2, 'push rbx'], + [3, 'sub rsp, 0x20'], + [7, 'mov rsi, rcx'], + [10, 'mov rdi, qword [rcx + 0x58]'], + [14, 'mov ebx, edi'], + [16, 'and ebx, 0x1400'], + [22, 'call 0x16c5d35'], + ], + }) + ); const { profile } = getProfileFromTextSamples(` - A[file:hg:hg.mozilla.org/mozilla-central:${filepath}:${revision}][line:4] + A[file:hg:hg.mozilla.org/mozilla-central:${filepath}:${revision}][line:4][address:30][sym:Asym:20:1a][lib:libA.so] B[file:git:github.com/rust-lang/rust:library/std/src/sys/unix/thread.rs:53cb7b09b00cbea8754ffb78e7e3cb521cb8af4b] C[lib:libC.so][file:s3:gecko-generated-sources:a5d3747707d6877b0e5cb0a364e3cb9fea8aa4feb6ead138952c2ba46d41045297286385f0e0470146f49403e46bd266e654dfca986de48c230f3a71c2aafed4/ipc/ipdl/PBackgroundChild.cpp:] D[lib:libD.so] @@ -75,7 +97,7 @@ describe('SourceView', () => { updateUrlState( stateFromLocation({ pathname: '/from-browser', - search: '', + search: '?symbolServer=http://127.0.0.1:8000', hash: '', }) ) @@ -89,22 +111,25 @@ describe('SourceView', () => { return { sourceView: () => document.querySelector('.sourceView'), + assemblyView: () => document.querySelector('.assemblyView'), }; } it('does not show the source view at loadtime', () => { - const { sourceView } = setup(); + const { sourceView, assemblyView } = setup(); expect(sourceView()).not.toBeInTheDocument(); + expect(assemblyView()).not.toBeInTheDocument(); }); - it('should show the source view when double clicking on a line in the tree view', async () => { - const { sourceView } = setup(); + it('should show the source view when a line in the tree view is double-clicked', async () => { + const { sourceView, assemblyView } = setup(); const frameElement = screen.getByRole('treeitem', { name: /^A/ }); fireFullClick(frameElement); fireFullClick(frameElement, { detail: 2 }); expect(sourceView()).toBeInTheDocument(); + expect(assemblyView()).not.toBeInTheDocument(); const sourceViewElement = ensureExists(sourceView()); const sourceViewContent = await within(sourceViewElement).findByRole( @@ -117,4 +142,44 @@ describe('SourceView', () => { expect(sourceViewContent).toMatchSnapshot(); }); + + it('should show the assembly view when pressing the toggle button', async () => { + const { sourceView, assemblyView } = setup(); + + const frameElement = screen.getByRole('treeitem', { name: /^A/ }); + + fireFullClick(frameElement); + fireFullClick(frameElement, { detail: 2 }); + expect(sourceView()).toBeInTheDocument(); + expect(assemblyView()).not.toBeInTheDocument(); + + const asmViewShowButton = ensureExists( + document.querySelector('.bottom-assembly-button') + ); + fireFullClick(asmViewShowButton); + + expect(sourceView()).toBeInTheDocument(); + expect(assemblyView()).toBeInTheDocument(); + + const assemblyViewElement = ensureExists(assemblyView()); + const assemblyViewContent = await within(assemblyViewElement).findByRole( + 'textbox' + ); + + // Find one of the instructions. Once we have assembly syntax highlighting, + // we'll probably have to match on a smaller string. + await within(assemblyViewContent).findAllByText( + 'mov rdi, qword [rcx + 0x58]' + ); + + expect(assemblyViewContent).toMatchSnapshot(); + + // Click the toggle button again and make sure the assembly view hides. + const asmViewHideButton = ensureExists( + document.querySelector('.bottom-assembly-button') + ); + fireFullClick(asmViewHideButton); + + expect(assemblyView()).not.toBeInTheDocument(); + }); }); diff --git a/src/test/components/__snapshots__/SourceView.test.js.snap b/src/test/components/__snapshots__/BottomBox.test.js.snap similarity index 60% rename from src/test/components/__snapshots__/SourceView.test.js.snap rename to src/test/components/__snapshots__/BottomBox.test.js.snap index 2db3a04310..9620f6c330 100644 --- a/src/test/components/__snapshots__/SourceView.test.js.snap +++ b/src/test/components/__snapshots__/BottomBox.test.js.snap @@ -1,6 +1,67 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`SourceView should show the source view when double clicking on a line in the tree view 1`] = ` +exports[`BottomBox should show the assembly view when pressing the toggle button 1`] = ` + + + push rsi + + + push rdi + + + push rbx + + + sub rsp, 0x20 + + + mov rsi, rcx + + + mov rdi, qword [rcx + 0x58] + + + mov ebx, edi + + + and ebx, 0x1400 + + + call 0x16c5d35 + + +`; + +exports[`BottomBox should show the source view when a line in the tree view is double-clicked 1`] = ` (); + +// A "state field" for the timings. +const timingsField = StateField.define({ + create() { + return emptyLineTimings; + }, + update(timings, transaction) { + // This is like a reducer. Find an updateTimingsEffect in the transaction + // and set this field to the timings in it. + let newTimings = timings; + for (const effect of transaction.effects) { + if (effect.is(updateTimingsEffect)) { + newTimings = effect.value; + } + } + return newTimings; + }, +}); + +// Finds all lines with non-zero line timings, for the highlight line. +// The line numbers are then converted into "positions", i.e. character offsets +// in the document, for the start of the line. +// Then they are sorted, because our caller wants to have a sorted list. +function getSortedStartPositionsOfNonZeroLines(state: EditorState): number[] { + const timings = state.field(timingsField); + const nonZeroLines = new Set(); + for (const lineNumber of timings.totalLineHits.keys()) { + nonZeroLines.add(lineNumber); + } + for (const lineNumber of timings.selfLineHits.keys()) { + nonZeroLines.add(lineNumber); + } + const lineCount = state.doc.lines; + const positions = [...nonZeroLines] + .filter((l) => l <= lineCount) + .map((lineNumber) => state.doc.line(lineNumber).from); + positions.sort((a, b) => a - b); + return positions; +} + +// This is an "extension" which applies the "cm-nonZeroLine" class to all gutter +// elements for lines with non-zero timings. It is like a piece of derived state; +// it needs to be recomputed whenever one of the input states change. The input +// states are the editor contents ("doc") and the value of the timings field. +// The editor contents are relevant because the output is expressed in terms of +// positions, i.e. character offsets from the document start, and those positions +// need to be updated if the amount of text in a line changes. This happens when +// we replace the file placeholder content with the actual file content. +const nonZeroLineGutterHighlighter = gutterLineClass.compute( + ['doc', timingsField], + (state) => { + const positions = getSortedStartPositionsOfNonZeroLines(state); + return RangeSet.of(positions.map((p) => nonZeroLineGutterMarker.range(p))); + } +); + +// Same as the previous extension, but this one is for the main editor. There +// doesn't seem to be a way to set a class for the entire line, i.e. both the +// gutter elements and the main editor elements of that line. +const nonZeroLineDecorationHighlighter = EditorView.decorations.compute( + ['doc', timingsField], + (state) => { + const positions = getSortedStartPositionsOfNonZeroLines(state); + return RangeSet.of(positions.map((p) => nonZeroLineDecoration.range(p))); + } +); + +// This is a "gutter marker" which renders just a string and nothing else. +// It is used for the AddressTimings annotations, i.e. for the numbers in the +// gutter. +export class StringMarker extends GutterMarker { + _s: string; + + constructor(s: string) { + super(); + this._s = s; + } + + toDOM() { + return document.createTextNode(this._s); + } +} + +// The "extension" which manages the elements in the gutter for the "total" +// column. +const totalTimingsGutter = gutter({ + class: 'cm-total-timings-gutter', + lineMarker(view, line) { + // Return a gutter marker for this line, or null. + const lineNumber = view.state.doc.lineAt(line.from).number; + const timings = view.state.field(timingsField); + const totalTime = timings.totalLineHits.get(lineNumber); + return totalTime !== undefined ? new StringMarker(totalTime) : null; + }, + lineMarkerChange(update) { + // Return true if the update affects the total timings in the gutter. + return update.transactions.some((t) => + t.effects.some((e) => e.is(updateTimingsEffect)) + ); + }, +}); + +// The "extension" which manages the elements in the gutter for the "self" +// column. +const selfTimingsGutter = gutter({ + class: 'cm-self-timings-gutter', + lineMarker(view, line) { + // Return a gutter marker for this line, or null. + const lineNumber = view.state.doc.lineAt(line.from).number; + const timings = view.state.field(timingsField); + const selfTime = timings.selfLineHits.get(lineNumber); + return selfTime !== undefined ? new StringMarker(selfTime) : null; + }, + lineMarkerChange(update) { + // Return true if the update affects the self timings in the gutter. + return update.transactions.some((t) => + t.effects.some((e) => e.is(updateTimingsEffect)) + ); + }, +}); + +// All extensions which have to do with timings, grouped into one extension. +export const timingsExtension = [ + timingsField, + totalTimingsGutter, + selfTimingsGutter, + nonZeroLineGutterHighlighter, + nonZeroLineDecorationHighlighter, +]; diff --git a/src/utils/index.js b/src/utils/index.js index a2f2922f78..6356a69c8c 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -120,3 +120,21 @@ export function countPositiveValues(arr: Array): number { } return count; } + +/** + * Find the highest number among the map values and return its key. + * Returns undefined if the map is empty. + * If multiple entries with the highest value exist, it returns the key of the + * first encountered highest value. + */ +export function mapGetKeyWithMaxValue(map: Map): K | void { + let maxValue = -Infinity; + let keyForMaxValue; + for (const [key, value] of map) { + if (value > maxValue) { + maxValue = value; + keyForMaxValue = key; + } + } + return keyForMaxValue; +}
+ See + + issue #3741 + + for supported scenarios and planned improvements. +
+ See + - - See - - issue #3741 - - for supported scenarios and planned improvements. - - - - {status.errors.map((error, key) => { - switch (error.type) { - case 'NO_KNOWN_CORS_URL': { - return ( - - No known cross-origin-accessible URL. - - ); - } - case 'NETWORK_ERROR': { - const { url, networkErrorMessage } = error; - return ( - - {`There was a network error when fetching the URL ${url}: ${networkErrorMessage}`} - - ); - } - case 'BROWSER_CONNECTION_ERROR': { - const { browserConnectionErrorMessage } = error; - return ( - - {`Could not query the browser’s symbolication API: ${browserConnectionErrorMessage}`} - - ); - } - case 'BROWSER_API_ERROR': { - const { apiErrorMessage } = error; - return ( - - {`The browser’s symbolication API returned an error: ${apiErrorMessage}`} - - ); - } - case 'SYMBOL_SERVER_API_ERROR': { - const { apiErrorMessage } = error; - return ( - - {`The local symbol server’s symbolication API returned an error: ${apiErrorMessage}`} - - ); - } - case 'BROWSER_API_MALFORMED_RESPONSE': { - const { errorMessage } = error; - return ( - - {`The browser’s symbolication API returned a malformed response: ${errorMessage}`} - - ); - } - case 'SYMBOL_SERVER_API_MALFORMED_RESPONSE': { - const { errorMessage } = error; - return ( - - {`The local symbol server’s symbolication API returned a malformed response: ${errorMessage}`} - - ); - } - case 'NOT_PRESENT_IN_ARCHIVE': { - const { url, pathInArchive } = error; - return ( - - {`The file ${pathInArchive} was not found in the archive from ${url}.`} - - ); - } - case 'ARCHIVE_PARSING_ERROR': { - const { url, parsingErrorMessage } = error; - return ( - - {`The archive at ${url} could not be parsed: ${parsingErrorMessage}`} - - ); - } - default: - throw assertExhaustiveCheck(error.type); - } - })} - -
- See - - issue #3741 - - for supported scenarios and planned improvements. -