Skip to content

Commit edac0dd

Browse files
authored
[DevTools] Add a Code Editor Sidebar Pane in the Chrome Sources Tab (facebook#33968)
This adds a "Code Editor" pane for the Chrome extension in the bottom right corner of the "Sources" panel. If you end up getting linked to the "Sources" panel from stack traces in console, performance tab, stacks in React Component tab like the one added in facebook#33954 basically everywhere there's a link to source code. Then going from there to open in a code editor should be more convenient. This adds a button to open the current file. <img width="1387" height="389" alt="Screenshot 2025-07-22 at 10 22 19 PM" src="https://github.com/user-attachments/assets/fe01f84c-83c2-4639-9b64-4af1a90c3f7d" /> This only makes sense in the extensions since in standalone it needs to always open by default in an editor. Unfortunately Firefox doesn't support extending the Sources panel. Chrome is also a bit buggy where it doesn't send a selection update event when you switch tabs in the Sources panel. Only when the actual cursor position changes. This means that the link can be lagging behind sometimes. We also have some general bugs where if React DevTools loses connection it can break the UI which includes this pane too. This has a small inline configuration too so that it's discoverable: <img width="559" height="143" alt="Screenshot 2025-07-22 at 10 22 42 PM" src="https://github.com/user-attachments/assets/1270bda8-ce10-4f9d-9fcb-080c0198366a" /> <img width="527" height="123" alt="Screenshot 2025-07-22 at 10 22 30 PM" src="https://github.com/user-attachments/assets/45848c95-afd8-495f-a7cf-eb2f46e698f2" /> Since we can't add a separate link to open-in-editor or open-in-sources everywhere I plan on adding an option to open in editor by default in a follow up. That option needs to be even more discoverable. I moved the configuration from the Components settings to the General settings since this is now a much more general features for opening links to resources in all types of panes. <img width="673" height="311" alt="Screenshot 2025-07-22 at 10 22 57 PM" src="https://github.com/user-attachments/assets/ea2c0871-942c-4b55-a362-025835d2c2bd" />
1 parent 3586a7f commit edac0dd

File tree

15 files changed

+496
-104
lines changed

15 files changed

+496
-104
lines changed

packages/react-devtools-extensions/src/main/index.js

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
/* global chrome */
22

3+
import type {SourceSelection} from 'react-devtools-shared/src/devtools/views/Editor/EditorPane';
4+
35
import {createElement} from 'react';
46
import {flushSync} from 'react-dom';
57
import {createRoot} from 'react-dom/client';
@@ -73,19 +75,60 @@ function createBridge() {
7375
);
7476
});
7577

78+
const sourcesPanel = chrome.devtools.panels.sources;
79+
7680
const onBrowserElementSelectionChanged = () =>
7781
setReactSelectionFromBrowser(bridge);
82+
const onBrowserSourceSelectionChanged = (___location: {
83+
url: string,
84+
startLine: number,
85+
startColumn: number,
86+
endLine: number,
87+
endColumn: number,
88+
}) => {
89+
if (
90+
currentSelectedSource === null ||
91+
currentSelectedSource.url !== ___location.url
92+
) {
93+
currentSelectedSource = {
94+
url: ___location.url,
95+
selectionRef: {
96+
// We use 1-based line and column, Chrome provides them 0-based.
97+
line: ___location.startLine + 1,
98+
column: ___location.startColumn + 1,
99+
},
100+
};
101+
// Rerender with the new file selection.
102+
render();
103+
} else {
104+
// Update the ref to the latest position without updating the url. No need to rerender.
105+
const selectionRef = currentSelectedSource.selectionRef;
106+
selectionRef.line = ___location.startLine + 1;
107+
selectionRef.column = ___location.startColumn + 1;
108+
}
109+
};
78110
const onBridgeShutdown = () => {
79111
chrome.devtools.panels.elements.onSelectionChanged.removeListener(
80112
onBrowserElementSelectionChanged,
81113
);
114+
if (sourcesPanel) {
115+
currentSelectedSource = null;
116+
sourcesPanel.onSelectionChanged.removeListener(
117+
onBrowserSourceSelectionChanged,
118+
);
119+
}
82120
};
83121

84122
bridge.addListener('shutdown', onBridgeShutdown);
85123

86124
chrome.devtools.panels.elements.onSelectionChanged.addListener(
87125
onBrowserElementSelectionChanged,
88126
);
127+
if (sourcesPanel) {
128+
sourcesPanel.onSelectionChanged.addListener(
129+
onBrowserSourceSelectionChanged,
130+
);
131+
}
89132
}
90133

91134
function createBridgeAndStore() {
@@ -152,11 +195,13 @@ function createBridgeAndStore() {
152195
bridge,
153196
browserTheme: getBrowserTheme(),
154197
componentsPortalContainer,
198+
profilerPortalContainer,
199+
editorPortalContainer,
200+
currentSelectedSource,
155201
enabledInspectedElementContextMenu: true,
156202
fetchFileWithCaching,
157203
hookNamesModuleLoaderFunction,
158204
overrideTab,
159-
profilerPortalContainer,
160205
showTabBar: false,
161206
store,
162207
warnIfUnsupportedVersionDetected: true,
@@ -257,6 +302,53 @@ function createProfilerPanel() {
257302
);
258303
}
259304

305+
function createSourcesEditorPanel() {
306+
if (editorPortalContainer) {
307+
// Panel is created and user opened it at least once
308+
ensureInitialHTMLIsCleared(editorPortalContainer);
309+
render();
310+
311+
return;
312+
}
313+
314+
if (editorPane) {
315+
// Panel is created, but wasn't opened yet, so no document is present for it
316+
return;
317+
}
318+
319+
const sourcesPanel = chrome.devtools.panels.sources;
320+
if (!sourcesPanel) {
321+
// Firefox doesn't currently support extending the source panel.
322+
return;
323+
}
324+
325+
sourcesPanel.createSidebarPane('Code Editor ⚛', createdPane => {
326+
editorPane = createdPane;
327+
328+
createdPane.setPage('panel.html');
329+
createdPane.setHeight('42px');
330+
331+
createdPane.onShown.addListener(portal => {
332+
editorPortalContainer = portal.container;
333+
if (editorPortalContainer != null && render) {
334+
ensureInitialHTMLIsCleared(editorPortalContainer);
335+
336+
render();
337+
portal.injectStyles(cloneStyleTags);
338+
339+
logEvent({event_name: 'selected-editor-pane'});
340+
}
341+
});
342+
343+
createdPane.onShown.addListener(() => {
344+
bridge.emit('extensionEditorPaneShown');
345+
});
346+
createdPane.onHidden.addListener(() => {
347+
bridge.emit('extensionEditorPaneHidden');
348+
});
349+
});
350+
}
351+
260352
function performInTabNavigationCleanup() {
261353
// Potentially, if react hasn't loaded yet and user performs in-tab navigation
262354
clearReactPollingInstance();
@@ -356,6 +448,7 @@ function mountReactDevTools() {
356448

357449
createComponentsPanel();
358450
createProfilerPanel();
451+
createSourcesEditorPanel();
359452
}
360453

361454
let reactPollingInstance = null;
@@ -394,13 +487,17 @@ let profilingData = null;
394487

395488
let componentsPanel = null;
396489
let profilerPanel = null;
490+
let editorPane = null;
397491
let componentsPortalContainer = null;
398492
let profilerPortalContainer = null;
493+
let editorPortalContainer = null;
399494

400495
let mostRecentOverrideTab = null;
401496
let render = null;
402497
let root = null;
403498

499+
let currentSelectedSource: null | SourceSelection = null;
500+
404501
let port = null;
405502

406503
// In case when multiple navigation events emitted in a short period of time
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.ButtonLabel {
2+
padding-left: 1.5rem;
3+
margin-left: -1rem;
4+
user-select: none;
5+
flex: 1 0 auto;
6+
text-align: center;
7+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import * as React from 'react';
11+
12+
import styles from './ButtonLabel.css';
13+
14+
type Props = {
15+
children: React$Node,
16+
};
17+
18+
export default function ButtonLabel({children}: Props): React.Node {
19+
return <span className={styles.ButtonLabel}>{children}</span>;
20+
}

packages/react-devtools-shared/src/devtools/views/Components/OpenInEditorButton.js

Lines changed: 2 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -14,64 +14,14 @@ import ButtonIcon from 'react-devtools-shared/src/devtools/views/ButtonIcon';
1414

1515
import type {ReactFunctionLocation} from 'shared/ReactTypes';
1616

17+
import {checkConditions} from '../Editor/utils';
18+
1719
type Props = {
1820
editorURL: string,
1921
source: ReactFunctionLocation,
2022
symbolicatedSourcePromise: Promise<ReactFunctionLocation | null>,
2123
};
2224

23-
function checkConditions(
24-
editorURL: string,
25-
source: ReactFunctionLocation,
26-
): {url: URL | null, shouldDisableButton: boolean} {
27-
try {
28-
const url = new URL(editorURL);
29-
30-
const [, sourceURL, line] = source;
31-
let filePath;
32-
33-
// Check if sourceURL is a correct URL, which has a protocol specified
34-
if (sourceURL.startsWith('file:///')) {
35-
filePath = new URL(sourceURL).pathname;
36-
} else if (sourceURL.includes('://')) {
37-
// $FlowFixMe[cannot-resolve-name]
38-
if (!__IS_INTERNAL_VERSION__) {
39-
// In this case, we can't really determine the path to a file, disable a button
40-
return {url: null, shouldDisableButton: true};
41-
} else {
42-
const endOfSourceMapURLPattern = '.js/';
43-
const endOfSourceMapURLIndex = sourceURL.lastIndexOf(
44-
endOfSourceMapURLPattern,
45-
);
46-
47-
if (endOfSourceMapURLIndex === -1) {
48-
return {url: null, shouldDisableButton: true};
49-
} else {
50-
filePath = sourceURL.slice(
51-
endOfSourceMapURLIndex + endOfSourceMapURLPattern.length,
52-
sourceURL.length,
53-
);
54-
}
55-
}
56-
} else {
57-
filePath = sourceURL;
58-
}
59-
60-
const lineNumberAsString = String(line);
61-
62-
url.href = url.href
63-
.replace('{path}', filePath)
64-
.replace('{line}', lineNumberAsString)
65-
.replace('%7Bpath%7D', filePath)
66-
.replace('%7Bline%7D', lineNumberAsString);
67-
68-
return {url, shouldDisableButton: false};
69-
} catch (e) {
70-
// User has provided incorrect editor url
71-
return {url: null, shouldDisableButton: true};
72-
}
73-
}
74-
7525
function OpenInEditorButton({
7626
editorURL,
7727
source,

packages/react-devtools-shared/src/devtools/views/DevTools.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
import Components from './Components/Components';
2525
import Profiler from './Profiler/Profiler';
2626
import TabBar from './TabBar';
27+
import EditorPane from './Editor/EditorPane';
2728
import {SettingsContextController} from './Settings/SettingsContext';
2829
import {TreeContextController} from './Components/TreeContext';
2930
import ViewElementSourceContext from './Components/ViewElementSourceContext';
@@ -51,6 +52,7 @@ import type {HookNamesModuleLoaderFunction} from 'react-devtools-shared/src/devt
5152
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
5253
import type {BrowserTheme} from 'react-devtools-shared/src/frontend/types';
5354
import type {ReactFunctionLocation} from 'shared/ReactTypes';
55+
import type {SourceSelection} from './Editor/EditorPane';
5456

5557
export type TabID = 'components' | 'profiler';
5658

@@ -97,6 +99,8 @@ export type Props = {
9799
// but individual tabs (e.g. Components, Profiling) can be rendered into portals within their browser panels.
98100
componentsPortalContainer?: Element,
99101
profilerPortalContainer?: Element,
102+
editorPortalContainer?: Element,
103+
currentSelectedSource?: null | SourceSelection,
100104

101105
// Loads and parses source maps for function components
102106
// and extracts hook "names" based on the variables the hook return values get assigned to.
@@ -126,12 +130,14 @@ export default function DevTools({
126130
browserTheme = 'light',
127131
canViewElementSourceFunction,
128132
componentsPortalContainer,
133+
profilerPortalContainer,
134+
editorPortalContainer,
135+
currentSelectedSource,
129136
defaultTab = 'components',
130137
enabledInspectedElementContextMenu = false,
131138
fetchFileWithCaching,
132139
hookNamesModuleLoaderFunction,
133140
overrideTab,
134-
profilerPortalContainer,
135141
showTabBar = false,
136142
store,
137143
warnIfLegacyBackendDetected = false,
@@ -316,6 +322,12 @@ export default function DevTools({
316322
/>
317323
</div>
318324
</div>
325+
{editorPortalContainer ? (
326+
<EditorPane
327+
selectedSource={currentSelectedSource}
328+
portalContainer={editorPortalContainer}
329+
/>
330+
) : null}
319331
</ThemeProvider>
320332
</InspectedElementContextController>
321333
</TimelineContextController>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
.EditorPane {
2+
position: relative;
3+
display: flex;
4+
flex-direction: row;
5+
background-color: var(--color-background);
6+
color: var(--color-text);
7+
font-family: var(--font-family-sans);
8+
align-items: center;
9+
padding: 0.5rem;
10+
}
11+
12+
.EditorPane, .EditorPane * {
13+
box-sizing: border-box;
14+
-webkit-font-smoothing: var(--font-smoothing);
15+
}
16+
17+
.VRule {
18+
height: 20px;
19+
width: 1px;
20+
flex: 0 0 1px;
21+
margin: 0 0.5rem;
22+
background-color: var(--color-border);
23+
}
24+
25+
.WideButton {
26+
flex: 1 0 auto;
27+
display: flex;
28+
}

0 commit comments

Comments
 (0)