diff --git a/packages/react-devtools-extensions/src/main/index.js b/packages/react-devtools-extensions/src/main/index.js index bd95d79a4e72c..a82df1e226338 100644 --- a/packages/react-devtools-extensions/src/main/index.js +++ b/packages/react-devtools-extensions/src/main/index.js @@ -1,5 +1,7 @@ /* global chrome */ +import type {SourceSelection} from 'react-devtools-shared/src/devtools/views/Editor/EditorPane'; + import {createElement} from 'react'; import {flushSync} from 'react-dom'; import {createRoot} from 'react-dom/client'; @@ -73,12 +75,48 @@ function createBridge() { ); }); + const sourcesPanel = chrome.devtools.panels.sources; + const onBrowserElementSelectionChanged = () => setReactSelectionFromBrowser(bridge); + const onBrowserSourceSelectionChanged = (location: { + url: string, + startLine: number, + startColumn: number, + endLine: number, + endColumn: number, + }) => { + if ( + currentSelectedSource === null || + currentSelectedSource.url !== location.url + ) { + currentSelectedSource = { + url: location.url, + selectionRef: { + // We use 1-based line and column, Chrome provides them 0-based. + line: location.startLine + 1, + column: location.startColumn + 1, + }, + }; + // Rerender with the new file selection. + render(); + } else { + // Update the ref to the latest position without updating the url. No need to rerender. + const selectionRef = currentSelectedSource.selectionRef; + selectionRef.line = location.startLine + 1; + selectionRef.column = location.startColumn + 1; + } + }; const onBridgeShutdown = () => { chrome.devtools.panels.elements.onSelectionChanged.removeListener( onBrowserElementSelectionChanged, ); + if (sourcesPanel) { + currentSelectedSource = null; + sourcesPanel.onSelectionChanged.removeListener( + onBrowserSourceSelectionChanged, + ); + } }; bridge.addListener('shutdown', onBridgeShutdown); @@ -86,6 +124,11 @@ function createBridge() { chrome.devtools.panels.elements.onSelectionChanged.addListener( onBrowserElementSelectionChanged, ); + if (sourcesPanel) { + sourcesPanel.onSelectionChanged.addListener( + onBrowserSourceSelectionChanged, + ); + } } function createBridgeAndStore() { @@ -152,11 +195,13 @@ function createBridgeAndStore() { bridge, browserTheme: getBrowserTheme(), componentsPortalContainer, + profilerPortalContainer, + editorPortalContainer, + currentSelectedSource, enabledInspectedElementContextMenu: true, fetchFileWithCaching, hookNamesModuleLoaderFunction, overrideTab, - profilerPortalContainer, showTabBar: false, store, warnIfUnsupportedVersionDetected: true, @@ -257,6 +302,53 @@ function createProfilerPanel() { ); } +function createSourcesEditorPanel() { + if (editorPortalContainer) { + // Panel is created and user opened it at least once + ensureInitialHTMLIsCleared(editorPortalContainer); + render(); + + return; + } + + if (editorPane) { + // Panel is created, but wasn't opened yet, so no document is present for it + return; + } + + const sourcesPanel = chrome.devtools.panels.sources; + if (!sourcesPanel) { + // Firefox doesn't currently support extending the source panel. + return; + } + + sourcesPanel.createSidebarPane('Code Editor ⚛', createdPane => { + editorPane = createdPane; + + createdPane.setPage('panel.html'); + createdPane.setHeight('42px'); + + createdPane.onShown.addListener(portal => { + editorPortalContainer = portal.container; + if (editorPortalContainer != null && render) { + ensureInitialHTMLIsCleared(editorPortalContainer); + + render(); + portal.injectStyles(cloneStyleTags); + + logEvent({event_name: 'selected-editor-pane'}); + } + }); + + createdPane.onShown.addListener(() => { + bridge.emit('extensionEditorPaneShown'); + }); + createdPane.onHidden.addListener(() => { + bridge.emit('extensionEditorPaneHidden'); + }); + }); +} + function performInTabNavigationCleanup() { // Potentially, if react hasn't loaded yet and user performs in-tab navigation clearReactPollingInstance(); @@ -356,6 +448,7 @@ function mountReactDevTools() { createComponentsPanel(); createProfilerPanel(); + createSourcesEditorPanel(); } let reactPollingInstance = null; @@ -394,13 +487,17 @@ let profilingData = null; let componentsPanel = null; let profilerPanel = null; +let editorPane = null; let componentsPortalContainer = null; let profilerPortalContainer = null; +let editorPortalContainer = null; let mostRecentOverrideTab = null; let render = null; let root = null; +let currentSelectedSource: null | SourceSelection = null; + let port = null; // In case when multiple navigation events emitted in a short period of time diff --git a/packages/react-devtools-shared/src/devtools/views/ButtonLabel.css b/packages/react-devtools-shared/src/devtools/views/ButtonLabel.css new file mode 100644 index 0000000000000..5a0656794568a --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/ButtonLabel.css @@ -0,0 +1,7 @@ +.ButtonLabel { + padding-left: 1.5rem; + margin-left: -1rem; + user-select: none; + flex: 1 0 auto; + text-align: center; +} diff --git a/packages/react-devtools-shared/src/devtools/views/ButtonLabel.js b/packages/react-devtools-shared/src/devtools/views/ButtonLabel.js new file mode 100644 index 0000000000000..3c8cf42f27702 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/ButtonLabel.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import * as React from 'react'; + +import styles from './ButtonLabel.css'; + +type Props = { + children: React$Node, +}; + +export default function ButtonLabel({children}: Props): React.Node { + return {children}; +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/OpenInEditorButton.js b/packages/react-devtools-shared/src/devtools/views/Components/OpenInEditorButton.js index a0f274490f068..a0ea948c809ad 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/OpenInEditorButton.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/OpenInEditorButton.js @@ -4,6 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * + * @flow */ import * as React from 'react'; @@ -13,58 +14,14 @@ import ButtonIcon from 'react-devtools-shared/src/devtools/views/ButtonIcon'; import type {ReactFunctionLocation} from 'shared/ReactTypes'; +import {checkConditions} from '../Editor/utils'; + type Props = { editorURL: string, source: ReactFunctionLocation, symbolicatedSourcePromise: Promise, }; -function checkConditions( - editorURL: string, - source: ReactFunctionLocation, -): {url: URL | null, shouldDisableButton: boolean} { - try { - const url = new URL(editorURL); - - let [, sourceURL, ,] = source; - - // Check if sourceURL is a correct URL, which has a protocol specified - if (sourceURL.includes('://')) { - if (!__IS_INTERNAL_VERSION__) { - // In this case, we can't really determine the path to a file, disable a button - return {url: null, shouldDisableButton: true}; - } else { - const endOfSourceMapURLPattern = '.js/'; - const endOfSourceMapURLIndex = sourceURL.lastIndexOf( - endOfSourceMapURLPattern, - ); - - if (endOfSourceMapURLIndex === -1) { - return {url: null, shouldDisableButton: true}; - } else { - sourceURL = sourceURL.slice( - endOfSourceMapURLIndex + endOfSourceMapURLPattern.length, - sourceURL.length, - ); - } - } - } - - const lineNumberAsString = String(source.line); - - url.href = url.href - .replace('{path}', sourceURL) - .replace('{line}', lineNumberAsString) - .replace('%7Bpath%7D', sourceURL) - .replace('%7Bline%7D', lineNumberAsString); - - return {url, shouldDisableButton: false}; - } catch (e) { - // User has provided incorrect editor url - return {url: null, shouldDisableButton: true}; - } -} - function OpenInEditorButton({ editorURL, source, diff --git a/packages/react-devtools-shared/src/devtools/views/DevTools.js b/packages/react-devtools-shared/src/devtools/views/DevTools.js index d18cee5540b7e..b84f3a11a95d1 100644 --- a/packages/react-devtools-shared/src/devtools/views/DevTools.js +++ b/packages/react-devtools-shared/src/devtools/views/DevTools.js @@ -24,6 +24,7 @@ import { import Components from './Components/Components'; import Profiler from './Profiler/Profiler'; import TabBar from './TabBar'; +import EditorPane from './Editor/EditorPane'; import {SettingsContextController} from './Settings/SettingsContext'; import {TreeContextController} from './Components/TreeContext'; import ViewElementSourceContext from './Components/ViewElementSourceContext'; @@ -51,6 +52,7 @@ import type {HookNamesModuleLoaderFunction} from 'react-devtools-shared/src/devt import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; import type {BrowserTheme} from 'react-devtools-shared/src/frontend/types'; import type {ReactFunctionLocation} from 'shared/ReactTypes'; +import type {SourceSelection} from './Editor/EditorPane'; export type TabID = 'components' | 'profiler'; @@ -97,6 +99,8 @@ export type Props = { // but individual tabs (e.g. Components, Profiling) can be rendered into portals within their browser panels. componentsPortalContainer?: Element, profilerPortalContainer?: Element, + editorPortalContainer?: Element, + currentSelectedSource?: null | SourceSelection, // Loads and parses source maps for function components // and extracts hook "names" based on the variables the hook return values get assigned to. @@ -126,12 +130,14 @@ export default function DevTools({ browserTheme = 'light', canViewElementSourceFunction, componentsPortalContainer, + profilerPortalContainer, + editorPortalContainer, + currentSelectedSource, defaultTab = 'components', enabledInspectedElementContextMenu = false, fetchFileWithCaching, hookNamesModuleLoaderFunction, overrideTab, - profilerPortalContainer, showTabBar = false, store, warnIfLegacyBackendDetected = false, @@ -316,6 +322,12 @@ export default function DevTools({ /> + {editorPortalContainer ? ( + + ) : null} diff --git a/packages/react-devtools-shared/src/devtools/views/Editor/EditorPane.css b/packages/react-devtools-shared/src/devtools/views/Editor/EditorPane.css new file mode 100644 index 0000000000000..00fa9bc44a523 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Editor/EditorPane.css @@ -0,0 +1,28 @@ +.EditorPane { + position: relative; + display: flex; + flex-direction: row; + background-color: var(--color-background); + color: var(--color-text); + font-family: var(--font-family-sans); + align-items: center; + padding: 0.5rem; +} + +.EditorPane, .EditorPane * { + box-sizing: border-box; + -webkit-font-smoothing: var(--font-smoothing); +} + +.VRule { + height: 20px; + width: 1px; + flex: 0 0 1px; + margin: 0 0.5rem; + background-color: var(--color-border); +} + +.WideButton { + flex: 1 0 auto; + display: flex; +} diff --git a/packages/react-devtools-shared/src/devtools/views/Editor/EditorPane.js b/packages/react-devtools-shared/src/devtools/views/Editor/EditorPane.js new file mode 100644 index 0000000000000..87ea39e724a3d --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Editor/EditorPane.js @@ -0,0 +1,83 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import * as React from 'react'; +import {useSyncExternalStore, useState, startTransition} from 'react'; + +import portaledContent from '../portaledContent'; + +import styles from './EditorPane.css'; + +import Button from 'react-devtools-shared/src/devtools/views/Button'; +import ButtonIcon from 'react-devtools-shared/src/devtools/views/ButtonIcon'; + +import OpenInEditorButton from './OpenInEditorButton'; +import {getOpenInEditorURL} from '../../../utils'; +import {LOCAL_STORAGE_OPEN_IN_EDITOR_URL} from '../../../constants'; + +import EditorSettings from './EditorSettings'; + +export type SourceSelection = { + url: string, + // The selection is a ref so that we don't have to rerender every keystroke. + selectionRef: { + line: number, + column: number, + }, +}; + +export type Props = {selectedSource: ?SourceSelection}; + +function EditorPane({selectedSource}: Props) { + const [showSettings, setShowSettings] = useState(false); + + const editorURL = useSyncExternalStore( + function subscribe(callback) { + window.addEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback); + return function unsubscribe() { + window.removeEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback); + }; + }, + function getState() { + return getOpenInEditorURL(); + }, + ); + + if (showSettings) { + return ( +
+ +
+ +
+ ); + } + + return ( +
+ +
+ +
+ ); +} +export default (portaledContent(EditorPane): React$ComponentType<{}>); diff --git a/packages/react-devtools-shared/src/devtools/views/Editor/EditorSettings.css b/packages/react-devtools-shared/src/devtools/views/Editor/EditorSettings.css new file mode 100644 index 0000000000000..f674441499be7 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Editor/EditorSettings.css @@ -0,0 +1,9 @@ +.EditorSettings { + display: flex; + flex: 1 0 auto; +} + +.EditorLabel { + display: inline; + margin-right: 0.5rem; +} diff --git a/packages/react-devtools-shared/src/devtools/views/Editor/EditorSettings.js b/packages/react-devtools-shared/src/devtools/views/Editor/EditorSettings.js new file mode 100644 index 0000000000000..40466cc778c4a --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Editor/EditorSettings.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import * as React from 'react'; + +import styles from './EditorSettings.css'; + +import CodeEditorOptions from '../Settings/CodeEditorOptions'; + +type Props = {}; + +function EditorSettings(_: Props): React.Node { + return ( +
+ +
+ ); +} + +export default EditorSettings; diff --git a/packages/react-devtools-shared/src/devtools/views/Editor/OpenInEditorButton.js b/packages/react-devtools-shared/src/devtools/views/Editor/OpenInEditorButton.js new file mode 100644 index 0000000000000..a3f0735eeb3fb --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Editor/OpenInEditorButton.js @@ -0,0 +1,71 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import * as React from 'react'; + +import Button from 'react-devtools-shared/src/devtools/views/Button'; +import ButtonIcon from 'react-devtools-shared/src/devtools/views/ButtonIcon'; +import ButtonLabel from 'react-devtools-shared/src/devtools/views/ButtonLabel'; + +import type {SourceSelection} from './EditorPane'; +import type {ReactFunctionLocation} from 'shared/ReactTypes'; + +import {checkConditions} from './utils'; + +type Props = { + editorURL: string, + source: ?SourceSelection, + className?: string, +}; + +function OpenInEditorButton({editorURL, source, className}: Props): React.Node { + let disable; + if (source == null) { + disable = true; + } else { + const staleLocation: ReactFunctionLocation = [ + '', + source.url, + // This is not live but we just use any line/column to validate whether this can be opened. + // We'll call checkConditions again when we click it to get the latest line number. + source.selectionRef.line, + source.selectionRef.column, + ]; + disable = checkConditions(editorURL, staleLocation).shouldDisableButton; + } + return ( + + ); +} + +export default OpenInEditorButton; diff --git a/packages/react-devtools-shared/src/devtools/views/Editor/utils.js b/packages/react-devtools-shared/src/devtools/views/Editor/utils.js new file mode 100644 index 0000000000000..89e697bd60c8d --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Editor/utils.js @@ -0,0 +1,62 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactFunctionLocation} from 'shared/ReactTypes'; + +export function checkConditions( + editorURL: string, + source: ReactFunctionLocation, +): {url: URL | null, shouldDisableButton: boolean} { + try { + const url = new URL(editorURL); + + const [, sourceURL, line] = source; + let filePath; + + // Check if sourceURL is a correct URL, which has a protocol specified + if (sourceURL.startsWith('file:///')) { + filePath = new URL(sourceURL).pathname; + } else if (sourceURL.includes('://')) { + // $FlowFixMe[cannot-resolve-name] + if (!__IS_INTERNAL_VERSION__) { + // In this case, we can't really determine the path to a file, disable a button + return {url: null, shouldDisableButton: true}; + } else { + const endOfSourceMapURLPattern = '.js/'; + const endOfSourceMapURLIndex = sourceURL.lastIndexOf( + endOfSourceMapURLPattern, + ); + + if (endOfSourceMapURLIndex === -1) { + return {url: null, shouldDisableButton: true}; + } else { + filePath = sourceURL.slice( + endOfSourceMapURLIndex + endOfSourceMapURLPattern.length, + sourceURL.length, + ); + } + } + } else { + filePath = sourceURL; + } + + const lineNumberAsString = String(line); + + url.href = url.href + .replace('{path}', filePath) + .replace('{line}', lineNumberAsString) + .replace('%7Bpath%7D', filePath) + .replace('%7Bline%7D', lineNumberAsString); + + return {url, shouldDisableButton: false}; + } catch (e) { + // User has provided incorrect editor url + return {url: null, shouldDisableButton: true}; + } +} diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/CodeEditorOptions.js b/packages/react-devtools-shared/src/devtools/views/Settings/CodeEditorOptions.js new file mode 100644 index 0000000000000..0eb489611b463 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Settings/CodeEditorOptions.js @@ -0,0 +1,65 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import * as React from 'react'; +import { + LOCAL_STORAGE_OPEN_IN_EDITOR_URL, + LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET, +} from '../../../constants'; +import {useLocalStorage} from '../hooks'; +import {getDefaultOpenInEditorURL} from 'react-devtools-shared/src/utils'; + +import styles from './SettingsShared.css'; + +const vscodeFilepath = 'vscode://file/{path}:{line}'; + +export default function ComponentsSettings({ + environmentNames, +}: { + environmentNames: Promise>, +}): React.Node { + const [openInEditorURLPreset, setOpenInEditorURLPreset] = useLocalStorage< + 'vscode' | 'custom', + >(LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET, 'custom'); + + const [openInEditorURL, setOpenInEditorURL] = useLocalStorage( + LOCAL_STORAGE_OPEN_IN_EDITOR_URL, + getDefaultOpenInEditorURL(), + ); + + return ( + <> + + {openInEditorURLPreset === 'custom' && ( + { + setOpenInEditorURL(event.target.value); + }} + /> + )} + + ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js b/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js index 106538cad3ab2..33e98835bb1b3 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js +++ b/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js @@ -17,11 +17,7 @@ import { useState, use, } from 'react'; -import { - LOCAL_STORAGE_OPEN_IN_EDITOR_URL, - LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET, -} from '../../../constants'; -import {useLocalStorage, useSubscription} from '../hooks'; +import {useSubscription} from '../hooks'; import {StoreContext} from '../context'; import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; @@ -45,7 +41,6 @@ import { ElementTypeActivity, ElementTypeViewTransition, } from 'react-devtools-shared/src/frontend/types'; -import {getDefaultOpenInEditorURL} from 'react-devtools-shared/src/utils'; import styles from './SettingsShared.css'; @@ -60,8 +55,6 @@ import type { } from 'react-devtools-shared/src/frontend/types'; import {isInternalFacebookBuild} from 'react-devtools-feature-flags'; -const vscodeFilepath = 'vscode://file/{path}:{line}'; - export default function ComponentsSettings({ environmentNames, }: { @@ -98,15 +91,6 @@ export default function ComponentsSettings({ [setParseHookNames], ); - const [openInEditorURLPreset, setOpenInEditorURLPreset] = useLocalStorage< - 'vscode' | 'custom', - >(LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET, 'custom'); - - const [openInEditorURL, setOpenInEditorURL] = useLocalStorage( - LOCAL_STORAGE_OPEN_IN_EDITOR_URL, - getDefaultOpenInEditorURL(), - ); - const [componentFilters, setComponentFilters] = useState< Array, >(() => [...store.componentFilters]); @@ -366,35 +350,6 @@ export default function ComponentsSettings({
- -
Hide components where...
diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/GeneralSettings.js b/packages/react-devtools-shared/src/devtools/views/Settings/GeneralSettings.js index 264cc05e60cfa..8246bc8593986 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/GeneralSettings.js +++ b/packages/react-devtools-shared/src/devtools/views/Settings/GeneralSettings.js @@ -13,6 +13,7 @@ import {SettingsContext} from './SettingsContext'; import {StoreContext} from '../context'; import {CHANGE_LOG_URL} from 'react-devtools-shared/src/devtools/constants'; import {isInternalFacebookBuild} from 'react-devtools-feature-flags'; +import CodeEditorOptions from './CodeEditorOptions'; import styles from './SettingsShared.css'; @@ -76,6 +77,13 @@ export default function GeneralSettings(_: {}): React.Node { +
+ +
+ {supportsTraceUpdates && (