@samitouri / QOS-React-2 / commits / 142fd27bf6

[DevTools] Add Option to Open Local Files directly in External Editor (#33983)

The `useOpenResource` hook is now used to open links. Currently, the `<>` icon for the component stacks and the link in the bottom of the components stack. But it'll also be used for many new links like stacks. If this new option is configured, and this is a local file then this is opened directly in the external editor. Otherwise it fallbacks to open in the Sources tab or whatever the standalone or inline is configured to use. <img width="453" height="252" alt="Screenshot 2025-07-24 at 4 09 09 PM" src="https://github.com/user-attachments/assets/04cae170-dd30-4485-a9ee-e8fe1612978e" /> I prominently surface this option in the Source pane to make it discoverable. <img width="588" height="144" alt="Screenshot 2025-07-24 at 4 03 48 PM" src="https://github.com/user-attachments/assets/0f3a7da9-2fae-4b5b-90ec-769c5a9c5361" /> When this is configured, the "Open in Editor" is hidden since that's just the default. I plan on deprecating this button to avoid having the two buttons going forward. Notably there's one exception where this doesn't work. When you click an Action or Event listener it takes you to the Sources tab and you have to open in editor from there. That's because we use the `inspect()` mechanism instead of extracting the source location. That's because we can't do the "throw trick" since these can have side-effects. The Chrome debugger protocol would solve this but it pops up an annoying dialog. We could maybe only attach the debugger only for that case. Especially if the dialog disappears before you focus on the browser again.

Sebastian Markbåge committed Jul 25, 2025 at 10:16 UTC 142fd27bf6e1b46c554d436509bdf9b70f7ef042
24 files changed +274 -140
packages/react-devtools-core/src/standalone.js
+5 -5
@@ -26,7 +26,7 @@ import {
26 import {localStorageSetItem} from 'react-devtools-shared/src/storage';
27
28 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
29 -import type {ReactFunctionLocation} from 'shared/ReactTypes';
29 +import type {ReactFunctionLocation, ReactCallSite} from 'shared/ReactTypes';
30
31 export type StatusTypes = 'server-connected' | 'devtools-connected' | 'error';
32 export type StatusListener = (message: string, status: StatusTypes) => void;
@@ -144,8 +144,8 @@ async function fetchFileWithCaching(url: string) {
144 }
145
146 function canViewElementSourceFunction(
147 - _source: ReactFunctionLocation,
148 - symbolicatedSource: ReactFunctionLocation | null,
147 + _source: ReactFunctionLocation | ReactCallSite,
148 + symbolicatedSource: ReactFunctionLocation | ReactCallSite | null,
149 ): boolean {
150 if (symbolicatedSource == null) {
151 return false;
@@ -156,8 +156,8 @@ function canViewElementSourceFunction(
156 }
157
158 function viewElementSourceFunction(
159 - _source: ReactFunctionLocation,
160 - symbolicatedSource: ReactFunctionLocation | null,
159 + _source: ReactFunctionLocation | ReactCallSite,
160 + symbolicatedSource: ReactFunctionLocation | ReactCallSite | null,
161 ): void {
162 if (symbolicatedSource == null) {
163 return;
packages/react-devtools-extensions/src/main/index.js
+1 -1
@@ -326,7 +326,7 @@ function createSourcesEditorPanel() {
326 editorPane = createdPane;
327
328 createdPane.setPage('panel.html');
329 - createdPane.setHeight('42px');
329 + createdPane.setHeight('75px');
330
331 createdPane.onShown.addListener(portal => {
332 editorPortalContainer = portal.container;
packages/react-devtools-fusebox/src/frontend.d.ts
+13 -4
@@ -34,17 +34,26 @@ export type ReactFunctionLocation = [
34 number, // enclosing line number
35 number, // enclosing column number
36 ];
37 +export type ReactCallSite = [
38 + string, // function name
39 + string, // file name TODO: model nested eval locations as nested arrays
40 + number, // line number
41 + number, // column number
42 + number, // enclosing line number
43 + number, // enclosing column number
44 + boolean, // async resume
45 +];
46 export type ViewElementSource = (
38 - source: ReactFunctionLocation,
39 - symbolicatedSource: ReactFunctionLocation | null,
47 + source: ReactFunctionLocation | ReactCallSite,
48 + symbolicatedSource: ReactFunctionLocation | ReactCallSite | null,
49 ) => void;
50 export type ViewAttributeSource = (
51 id: number,
52 path: Array<string | number>,
53 ) => void;
54 export type CanViewElementSource = (
46 - source: ReactFunctionLocation,
47 - symbolicatedSource: ReactFunctionLocation | null,
55 + source: ReactFunctionLocation | ReactCallSite,
56 + symbolicatedSource: ReactFunctionLocation | ReactCallSite | null,
57 ) => boolean;
58
59 export type InitializationOptions = {
packages/react-devtools-shared/src/__tests__/__serializers__/inspectedElementSerializer.js
+1 -2
@@ -15,8 +15,7 @@ export function test(maybeInspectedElement) {
15 hasOwnProperty('canEditFunctionProps') &&
16 hasOwnProperty('canEditHooks') &&
17 hasOwnProperty('canToggleSuspense') &&
18 - hasOwnProperty('canToggleError') &&
19 - hasOwnProperty('canViewSource')
18 + hasOwnProperty('canToggleError')
19 );
20 }
21
packages/react-devtools-shared/src/backend/fiber/renderer.js
-5
@@ -4374,8 +4374,6 @@ export function attach(
4374 (fiber.alternate !== null &&
4375 forceFallbackForFibers.has(fiber.alternate))),
4376
4377 - // Can view component source location.
4378 - canViewSource,
4377 source,
4378
4379 // Does the component have legacy context attached to it.
@@ -4416,7 +4414,6 @@ export function attach(
4414 function inspectVirtualInstanceRaw(
4415 virtualInstance: VirtualInstance,
4416 ): InspectedElement | null {
4419 - const canViewSource = true;
4417 const source = getSourceForInstance(virtualInstance);
4418
4419 const componentInfo = virtualInstance.data;
@@ -4470,8 +4467,6 @@ export function attach(
4467
4468 canToggleSuspense: supportsTogglingSuspense && hasSuspenseBoundary,
4469
4473 - // Can view component source location.
4474 - canViewSource,
4470 source,
4471
4472 // Does the component have legacy context attached to it.
packages/react-devtools-shared/src/backend/legacy/renderer.js
-2
@@ -830,8 +830,6 @@ export function attach(
830 // Suspense did not exist in legacy versions
831 canToggleSuspense: false,
832
833 - // Can view component source location.
834 - canViewSource: type === ElementTypeClass || type === ElementTypeFunction,
833 source: null,
834
835 // Only legacy context exists in legacy versions.
packages/react-devtools-shared/src/backend/types.js
-3
@@ -264,9 +264,6 @@ export type InspectedElement = {
264 // Is this Suspense, and can its value be overridden now?
265 canToggleSuspense: boolean,
266
267 - // Can view component source location.
268 - canViewSource: boolean,
269 -
267 // Does the component have legacy context attached to it.
268 hasLegacyContext: boolean,
269
packages/react-devtools-shared/src/backendAPI.js
-2
@@ -222,7 +222,6 @@ export function convertInspectedElementBackendToFrontend(
222 canToggleError,
223 isErrored,
224 canToggleSuspense,
225 - canViewSource,
225 hasLegacyContext,
226 id,
227 type,
@@ -252,7 +251,6 @@ export function convertInspectedElementBackendToFrontend(
251 canToggleError,
252 isErrored,
253 canToggleSuspense,
255 - canViewSource,
254 hasLegacyContext,
255 id,
256 key,
packages/react-devtools-shared/src/constants.js
+2
@@ -37,6 +37,8 @@ export const LOCAL_STORAGE_OPEN_IN_EDITOR_URL =
37 'React::DevTools::openInEditorUrl';
38 export const LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET =
39 'React::DevTools::openInEditorUrlPreset';
40 +export const LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR =
41 + 'React::DevTools::alwaysOpenInEditor';
42 export const LOCAL_STORAGE_PARSE_HOOK_NAMES_KEY =
43 'React::DevTools::parseHookNames';
44 export const SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY =
packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js
+24 -13
@@ -18,8 +18,11 @@ import Toggle from '../Toggle';
18 import {ElementTypeSuspense} from 'react-devtools-shared/src/frontend/types';
19 import InspectedElementView from './InspectedElementView';
20 import {InspectedElementContext} from './InspectedElementContext';
21 -import {getOpenInEditorURL} from '../../../utils';
22 -import {LOCAL_STORAGE_OPEN_IN_EDITOR_URL} from '../../../constants';
21 +import {getOpenInEditorURL, getAlwaysOpenInEditor} from '../../../utils';
22 +import {
23 + LOCAL_STORAGE_OPEN_IN_EDITOR_URL,
24 + LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR,
25 +} from '../../../constants';
26 import FetchFileWithCachingContext from './FetchFileWithCachingContext';
27 import {symbolicateSourceWithCache} from 'react-devtools-shared/src/symbolicateSource';
28 import OpenInEditorButton from './OpenInEditorButton';
@@ -118,18 +121,26 @@ export default function InspectedElementWrapper(_: Props): React.Node {
121 inspectedElement != null &&
122 inspectedElement.canToggleSuspense;
123
121 - const editorURL = useSyncExternalStore(
122 - function subscribe(callback) {
123 - window.addEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback);
124 + const alwaysOpenInEditor = useSyncExternalStore(
125 + useCallback(function subscribe(callback) {
126 + window.addEventListener(LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR, callback);
127 return function unsubscribe() {
125 - window.removeEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback);
128 + window.removeEventListener(
129 + LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR,
130 + callback,
131 + );
132 };
127 - },
128 - function getState() {
129 - return getOpenInEditorURL();
130 - },
133 + }, []),
134 + getAlwaysOpenInEditor,
135 );
136
137 + const editorURL = useSyncExternalStore(function subscribe(callback) {
138 + window.addEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback);
139 + return function unsubscribe() {
140 + window.removeEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback);
141 + };
142 + }, getOpenInEditorURL);
143 +
144 const toggleErrored = useCallback(() => {
145 if (inspectedElement == null) {
146 return;
@@ -217,7 +228,8 @@ export default function InspectedElementWrapper(_: Props): React.Node {
228 </div>
229 </div>
230
220 - {!!editorURL &&
231 + {!alwaysOpenInEditor &&
232 + !!editorURL &&
233 inspectedElement != null &&
234 inspectedElement.source != null &&
235 symbolicatedSourcePromise != null && (
@@ -271,8 +283,7 @@ export default function InspectedElementWrapper(_: Props): React.Node {
283
284 {!hideViewSourceAction && (
285 <InspectedElementViewSourceButton
274 - canViewSource={inspectedElement?.canViewSource}
275 - source={inspectedElement?.source}
286 + source={inspectedElement ? inspectedElement.source : null}
287 symbolicatedSourcePromise={symbolicatedSourcePromise}
288 />
289 )}
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js
+4 -18
@@ -8,7 +8,6 @@
8 */
9
10 import * as React from 'react';
11 -import {useCallback, useContext} from 'react';
11 import {copy} from 'clipboard-js';
12 import {toNormalUrl} from 'jsc-safe-url';
13
@@ -17,7 +16,7 @@ import ButtonIcon from '../ButtonIcon';
16 import Skeleton from './Skeleton';
17 import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
18
20 -import ViewElementSourceContext from './ViewElementSourceContext';
19 +import useOpenResource from '../useOpenResource';
20
21 import type {ReactFunctionLocation} from 'shared/ReactTypes';
22 import styles from './InspectedElementSourcePanel.css';
@@ -91,24 +90,11 @@ function CopySourceButton({source, symbolicatedSourcePromise}: Props) {
90 function FormattedSourceString({source, symbolicatedSourcePromise}: Props) {
91 const symbolicatedSource = React.use(symbolicatedSourcePromise);
92
94 - const {canViewElementSourceFunction, viewElementSourceFunction} = useContext(
95 - ViewElementSourceContext,
93 + const [linkIsEnabled, viewSource] = useOpenResource(
94 + source,
95 + symbolicatedSource,
96 );
97
98 - // In some cases (e.g. FB internal usage) the standalone shell might not be able to view the source.
99 - // To detect this case, we defer to an injected helper function (if present).
100 - const linkIsEnabled =
101 - viewElementSourceFunction != null &&
102 - source != null &&
103 - (canViewElementSourceFunction == null ||
104 - canViewElementSourceFunction(source, symbolicatedSource));
105 -
106 - const viewSource = useCallback(() => {
107 - if (viewElementSourceFunction != null && source != null) {
108 - viewElementSourceFunction(source, symbolicatedSource);
109 - }
110 - }, [source, symbolicatedSource]);
111 -
98 const [, sourceURL, line] =
99 symbolicatedSource == null ? source : symbolicatedSource;
100
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementViewSourceButton.js
+7 -38
@@ -11,79 +11,48 @@ import * as React from 'react';
11
12 import ButtonIcon from '../ButtonIcon';
13 import Button from '../Button';
14 -import ViewElementSourceContext from './ViewElementSourceContext';
14 import Skeleton from './Skeleton';
15
16 import type {ReactFunctionLocation} from 'shared/ReactTypes';
18 -import type {
19 - CanViewElementSource,
20 - ViewElementSource,
21 -} from 'react-devtools-shared/src/devtools/views/DevTools';
17
23 -const {useCallback, useContext} = React;
18 +import useOpenResource from '../useOpenResource';
19
20 type Props = {
26 - canViewSource: ?boolean,
27 - source: ?ReactFunctionLocation,
21 + source: null | ReactFunctionLocation,
22 symbolicatedSourcePromise: Promise<ReactFunctionLocation | null> | null,
23 };
24
25 function InspectedElementViewSourceButton({
32 - canViewSource,
26 source,
27 symbolicatedSourcePromise,
28 }: Props): React.Node {
36 - const {canViewElementSourceFunction, viewElementSourceFunction} = useContext(
37 - ViewElementSourceContext,
38 - );
39 -
29 return (
30 <React.Suspense fallback={<Skeleton height={16} width={24} />}>
31 <ActualSourceButton
43 - canViewSource={canViewSource}
32 source={source}
33 symbolicatedSourcePromise={symbolicatedSourcePromise}
46 - canViewElementSourceFunction={canViewElementSourceFunction}
47 - viewElementSourceFunction={viewElementSourceFunction}
34 />
35 </React.Suspense>
36 );
37 }
38
39 type ActualSourceButtonProps = {
54 - canViewSource: ?boolean,
55 - source: ?ReactFunctionLocation,
40 + source: null | ReactFunctionLocation,
41 symbolicatedSourcePromise: Promise<ReactFunctionLocation | null> | null,
57 - canViewElementSourceFunction: CanViewElementSource | null,
58 - viewElementSourceFunction: ViewElementSource | null,
42 };
43 function ActualSourceButton({
61 - canViewSource,
44 source,
45 symbolicatedSourcePromise,
64 - canViewElementSourceFunction,
65 - viewElementSourceFunction,
46 }: ActualSourceButtonProps): React.Node {
47 const symbolicatedSource =
48 symbolicatedSourcePromise == null
49 ? null
50 : React.use(symbolicatedSourcePromise);
51
72 - // In some cases (e.g. FB internal usage) the standalone shell might not be able to view the source.
73 - // To detect this case, we defer to an injected helper function (if present).
74 - const buttonIsEnabled =
75 - !!canViewSource &&
76 - viewElementSourceFunction != null &&
77 - source != null &&
78 - (canViewElementSourceFunction == null ||
79 - canViewElementSourceFunction(source, symbolicatedSource));
80 -
81 - const viewSource = useCallback(() => {
82 - if (viewElementSourceFunction != null && source != null) {
83 - viewElementSourceFunction(source, symbolicatedSource);
84 - }
85 - }, [source, symbolicatedSource]);
86 -
52 + const [buttonIsEnabled, viewSource] = useOpenResource(
53 + source,
54 + symbolicatedSource,
55 + );
56 return (
57 <Button
58 disabled={!buttonIsEnabled}
packages/react-devtools-shared/src/devtools/views/DevTools.js
+5 -5
@@ -51,22 +51,22 @@ import type {FetchFileWithCaching} from './Components/FetchFileWithCachingContex
51 import type {HookNamesModuleLoaderFunction} from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext';
52 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
53 import type {BrowserTheme} from 'react-devtools-shared/src/frontend/types';
54 -import type {ReactFunctionLocation} from 'shared/ReactTypes';
54 +import type {ReactFunctionLocation, ReactCallSite} from 'shared/ReactTypes';
55 import type {SourceSelection} from './Editor/EditorPane';
56
57 export type TabID = 'components' | 'profiler';
58
59 export type ViewElementSource = (
60 - source: ReactFunctionLocation,
61 - symbolicatedSource: ReactFunctionLocation | null,
60 + source: ReactFunctionLocation | ReactCallSite,
61 + symbolicatedSource: ReactFunctionLocation | ReactCallSite | null,
62 ) => void;
63 export type ViewAttributeSource = (
64 id: number,
65 path: Array<string | number>,
66 ) => void;
67 export type CanViewElementSource = (
68 - source: ReactFunctionLocation,
69 - symbolicatedSource: ReactFunctionLocation | null,
68 + source: ReactFunctionLocation | ReactCallSite,
69 + symbolicatedSource: ReactFunctionLocation | ReactCallSite | null,
70 ) => boolean;
71
72 export type Props = {
packages/react-devtools-shared/src/devtools/views/Editor/EditorPane.css
+14 -4
@@ -1,12 +1,9 @@
1 .EditorPane {
2 position: relative;
3 - display: flex;
4 - flex-direction: row;
3 + display: block;
4 background-color: var(--color-background);
5 color: var(--color-text);
6 font-family: var(--font-family-sans);
8 - align-items: center;
9 - padding: 0.5rem;
7 }
8
9 .EditorPane, .EditorPane * {
@@ -14,6 +11,19 @@
11 -webkit-font-smoothing: var(--font-smoothing);
12 }
13
14 +.EditorToolbar {
15 + display: flex;
16 + flex-direction: row;
17 + align-items: center;
18 + padding: 0.5rem;
19 + border-bottom: 1px solid var(--color-border);
20 +}
21 +
22 +.EditorInfo {
23 + padding: 0.5rem;
24 + text-align: center;
25 +}
26 +
27 .VRule {
28 height: 20px;
29 width: 1px;
packages/react-devtools-shared/src/devtools/views/Editor/EditorPane.js
+31 -16
@@ -22,6 +22,7 @@ import {getOpenInEditorURL} from '../../../utils';
22 import {LOCAL_STORAGE_OPEN_IN_EDITOR_URL} from '../../../constants';
23
24 import EditorSettings from './EditorSettings';
25 +import CodeEditorByDefault from '../Settings/CodeEditorByDefault';
26
27 export type SourceSelection = {
28 url: string,
@@ -49,9 +50,10 @@ function EditorPane({selectedSource}: Props) {
50 },
51 );
52
53 + let editorToolbar;
54 if (showSettings) {
53 - return (
54 - <div className={styles.EditorPane}>
55 + editorToolbar = (
56 + <div className={styles.EditorToolbar}>
57 <EditorSettings />
58 <div className={styles.VRule} />
59 <Button onClick={() => startTransition(() => setShowSettings(false))}>
@@ -59,24 +61,37 @@ function EditorPane({selectedSource}: Props) {
61 </Button>
62 </div>
63 );
64 + } else {
65 + editorToolbar = (
66 + <div className={styles.EditorToolbar}>
67 + <OpenInEditorButton
68 + className={styles.WideButton}
69 + editorURL={editorURL}
70 + source={selectedSource}
71 + />
72 + <div className={styles.VRule} />
73 + <Button
74 + onClick={() => startTransition(() => setShowSettings(true))}
75 + // We don't use the title here because we don't have enough space to show it.
76 + // Once we expand this pane we can add it.
77 + // title="Configure code editor"
78 + >
79 + <ButtonIcon type="settings" />
80 + </Button>
81 + </div>
82 + );
83 }
84
85 return (
86 <div className={styles.EditorPane}>
66 - <OpenInEditorButton
67 - className={styles.WideButton}
68 - editorURL={editorURL}
69 - source={selectedSource}
70 - />
71 - <div className={styles.VRule} />
72 - <Button
73 - onClick={() => startTransition(() => setShowSettings(true))}
74 - // We don't use the title here because we don't have enough space to show it.
75 - // Once we expand this pane we can add it.
76 - // title="Configure code editor"
77 - >
78 - <ButtonIcon type="settings" />
79 - </Button>
87 + {editorToolbar}
88 + <div className={styles.EditorInfo}>
89 + {editorURL ? (
90 + <CodeEditorByDefault />
91 + ) : (
92 + 'Configure an external editor to open local files.'
93 + )}
94 + </div>
95 </div>
96 );
97 }
packages/react-devtools-shared/src/devtools/views/Editor/utils.js
+2 -2
@@ -7,11 +7,11 @@
7 * @flow
8 */
9
10 -import type {ReactFunctionLocation} from 'shared/ReactTypes';
10 +import type {ReactFunctionLocation, ReactCallSite} from 'shared/ReactTypes';
11
12 export function checkConditions(
13 editorURL: string,
14 - source: ReactFunctionLocation,
14 + source: ReactFunctionLocation | ReactCallSite,
15 ): {url: URL | null, shouldDisableButton: boolean} {
16 try {
17 const url = new URL(editorURL);
packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js
+5 -13
@@ -12,7 +12,6 @@ import type {SchedulingEvent} from 'react-devtools-timeline/src/types';
12 import * as React from 'react';
13 import Button from '../Button';
14 import ButtonIcon from '../ButtonIcon';
15 -import ViewElementSourceContext from '../Components/ViewElementSourceContext';
15 import {useContext} from 'react';
16 import {TimelineContext} from 'react-devtools-timeline/src/TimelineContext';
17 import {
@@ -22,6 +21,7 @@ import {
21 import {stackToComponentLocations} from 'react-devtools-shared/src/devtools/utils';
22 import {copy} from 'clipboard-js';
23 import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
24 +import useOpenResource from '../useOpenResource';
25
26 import styles from './SidebarEventInfo.css';
27
@@ -32,9 +32,6 @@ type SchedulingEventProps = {
32 };
33
34 function SchedulingEventInfo({eventInfo}: SchedulingEventProps) {
35 - const {canViewElementSourceFunction, viewElementSourceFunction} = useContext(
36 - ViewElementSourceContext,
37 - );
35 const {componentName, timestamp} = eventInfo;
36 const componentStack = eventInfo.componentStack || null;
37
@@ -79,15 +76,10 @@ function SchedulingEventInfo({eventInfo}: SchedulingEventProps) {
76
77 // TODO: We should support symbolication here as well, but
78 // symbolicating the whole stack can be expensive
82 - const canViewSource =
83 - canViewElementSourceFunction == null ||
84 - canViewElementSourceFunction(location, null);
85 -
86 - const viewSource =
87 - !canViewSource || viewElementSourceFunction == null
88 - ? () => null
89 - : () => viewElementSourceFunction(location, null);
90 -
79 + const [canViewSource, viewSource] = useOpenResource(
80 + location,
81 + null,
82 + );
83 return (
84 <li key={index}>
85 <Button
packages/react-devtools-shared/src/devtools/views/Settings/CodeEditorByDefault.js new
+35
@@ -0,0 +1,35 @@
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 +import {LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR} from '../../../constants';
12 +import {useLocalStorage} from '../hooks';
13 +
14 +import styles from './SettingsShared.css';
15 +
16 +export default function CodeEditorByDefault(_: {}): React.Node {
17 + const [alwaysOpenInEditor, setAlwaysOpenInEditor] = useLocalStorage<boolean>(
18 + LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR,
19 + false,
20 + );
21 +
22 + return (
23 + <label className={styles.SettingRow}>
24 + <input
25 + type="checkbox"
26 + checked={alwaysOpenInEditor}
27 + onChange={({currentTarget}) =>
28 + setAlwaysOpenInEditor(currentTarget.checked)
29 + }
30 + className={styles.SettingRowCheckbox}
31 + />
32 + Open local files directly in your code editor
33 + </label>
34 + );
35 +}
packages/react-devtools-shared/src/devtools/views/Settings/CodeEditorOptions.js
+1 -1
@@ -19,7 +19,7 @@ import styles from './SettingsShared.css';
19
20 const vscodeFilepath = 'vscode://file/{path}:{line}';
21
22 -export default function ComponentsSettings({
22 +export default function CodeEditorOptions({
23 environmentNames,
24 }: {
25 environmentNames: Promise<Array<string>>,
packages/react-devtools-shared/src/devtools/views/Settings/GeneralSettings.js
+17
@@ -16,6 +16,7 @@ import {isInternalFacebookBuild} from 'react-devtools-feature-flags';
16 import CodeEditorOptions from './CodeEditorOptions';
17
18 import styles from './SettingsShared.css';
19 +import CodeEditorByDefault from './CodeEditorByDefault';
20
21 function getChangeLogUrl(version: ?string): string | null {
22 if (!version) {
@@ -84,6 +85,22 @@ export default function GeneralSettings(_: {}): React.Node {
85 </label>
86 </div>
87
88 + <div className={styles.SettingWrapper}>
89 + <CodeEditorByDefault />
90 + </div>
91 +
92 + <div className={styles.SettingWrapper}>
93 + <div className={styles.RadioLabel}>Display density</div>
94 + <select
95 + value={displayDensity}
96 + onChange={({currentTarget}) =>
97 + setDisplayDensity(currentTarget.value)
98 + }>
99 + <option value="compact">Compact</option>
100 + <option value="comfortable">Comfortable</option>
101 + </select>
102 + </div>
103 +
104 {supportsTraceUpdates && (
105 <div className={styles.SettingWrapper}>
106 <label className={styles.SettingRow}>
packages/react-devtools-shared/src/devtools/views/hooks.js
+13 -3
@@ -13,6 +13,7 @@ import {
13 useLayoutEffect,
14 useReducer,
15 useState,
16 + useSyncExternalStore,
17 useContext,
18 } from 'react';
19 import {
@@ -162,14 +163,24 @@ export function useLocalStorage<T>(
163 }
164 }, [initialValue, key]);
165
165 - const [storedValue, setStoredValue] = useState<any>(getValueFromLocalStorage);
166 + const storedValue = useSyncExternalStore(
167 + useCallback(
168 + function subscribe(callback) {
169 + window.addEventListener(key, callback);
170 + return function unsubscribe() {
171 + window.removeEventListener(key, callback);
172 + };
173 + },
174 + [key],
175 + ),
176 + getValueFromLocalStorage,
177 + );
178
179 const setValue = useCallback(
180 (value: $FlowFixMe) => {
181 try {
182 const valueToStore =
183 value instanceof Function ? (value: any)(storedValue) : value;
172 - setStoredValue(valueToStore);
184 localStorageSetItem(key, JSON.stringify(valueToStore));
185
186 // Notify listeners that this setting has changed.
@@ -197,7 +208,6 @@ export function useLocalStorage<T>(
208 };
209
210 window.addEventListener('storage', onStorage);
200 -
211 return () => {
212 window.removeEventListener('storage', onStorage);
213 };
packages/react-devtools-shared/src/devtools/views/useOpenResource.js new
+85
@@ -0,0 +1,85 @@
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 type {ReactFunctionLocation, ReactCallSite} from 'shared/ReactTypes';
11 +
12 +import {useCallback, useContext, useSyncExternalStore} from 'react';
13 +
14 +import ViewElementSourceContext from './Components/ViewElementSourceContext';
15 +
16 +import {getOpenInEditorURL, getAlwaysOpenInEditor} from '../../utils';
17 +import {
18 + LOCAL_STORAGE_OPEN_IN_EDITOR_URL,
19 + LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR,
20 +} from '../../constants';
21 +
22 +import {checkConditions} from './Editor/utils';
23 +
24 +const useOpenResource = (
25 + source: null | ReactFunctionLocation | ReactCallSite,
26 + symbolicatedSource: null | ReactFunctionLocation | ReactCallSite,
27 +): [
28 + boolean, // isEnabled
29 + () => void, // Open Resource
30 +] => {
31 + const {canViewElementSourceFunction, viewElementSourceFunction} = useContext(
32 + ViewElementSourceContext,
33 + );
34 +
35 + const editorURL = useSyncExternalStore(
36 + useCallback(function subscribe(callback) {
37 + window.addEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback);
38 + return function unsubscribe() {
39 + window.removeEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback);
40 + };
41 + }, []),
42 + getOpenInEditorURL,
43 + );
44 +
45 + const alwaysOpenInEditor = useSyncExternalStore(
46 + useCallback(function subscribe(callback) {
47 + window.addEventListener(LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR, callback);
48 + return function unsubscribe() {
49 + window.removeEventListener(
50 + LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR,
51 + callback,
52 + );
53 + };
54 + }, []),
55 + getAlwaysOpenInEditor,
56 + );
57 +
58 + // First check if this link is eligible for being open directly in the configured editor.
59 + const openInEditor =
60 + alwaysOpenInEditor && source !== null
61 + ? checkConditions(editorURL, symbolicatedSource || source)
62 + : null;
63 + // In some cases (e.g. FB internal usage) the standalone shell might not be able to view the source.
64 + // To detect this case, we defer to an injected helper function (if present).
65 + const linkIsEnabled =
66 + (openInEditor !== null && !openInEditor.shouldDisableButton) ||
67 + (viewElementSourceFunction != null &&
68 + source != null &&
69 + (canViewElementSourceFunction == null ||
70 + canViewElementSourceFunction(source, symbolicatedSource)));
71 +
72 + const viewSource = useCallback(() => {
73 + if (openInEditor !== null && !openInEditor.shouldDisableButton) {
74 + // If we have configured to always open in the code editor, we do so if we can.
75 + // Otherwise, we fallback to open in the local editor if possible (e.g. non-file urls).
76 + window.open(openInEditor.url);
77 + } else if (viewElementSourceFunction != null && source != null) {
78 + viewElementSourceFunction(source, symbolicatedSource);
79 + }
80 + }, [openInEditor, source, symbolicatedSource]);
81 +
82 + return [linkIsEnabled, viewSource];
83 +};
84 +
85 +export default useOpenResource;
packages/react-devtools-shared/src/frontend/types.js
-3
@@ -227,9 +227,6 @@ export type InspectedElement = {
227 // Is this Suspense, and can its value be overridden now?
228 canToggleSuspense: boolean,
229
230 - // Can view component source location.
231 - canViewSource: boolean,
232 -
230 // Does the component have legacy context attached to it.
231 hasLegacyContext: boolean,
232
packages/react-devtools-shared/src/utils.js
+9
@@ -35,6 +35,7 @@ import {
35 TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
36 LOCAL_STORAGE_COMPONENT_FILTER_PREFERENCES_KEY,
37 LOCAL_STORAGE_OPEN_IN_EDITOR_URL,
38 + LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR,
39 SESSION_STORAGE_RELOAD_AND_PROFILE_KEY,
40 SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY,
41 SESSION_STORAGE_RECORD_TIMELINE_KEY,
@@ -400,6 +401,14 @@ export function getOpenInEditorURL(): string {
401 return getDefaultOpenInEditorURL();
402 }
403
404 +export function getAlwaysOpenInEditor(): boolean {
405 + try {
406 + const raw = localStorageGetItem(LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR);
407 + return raw === 'true';
408 + } catch (error) {}
409 + return false;
410 +}
411 +
412 type ParseElementDisplayNameFromBackendReturn = {
413 formattedDisplayName: string | null,
414 hocDisplayNames: Array<string> | null,