react/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js
Sebastian Markbåge edac0dded9
[DevTools] Add a Code Editor Sidebar Pane in the Chrome Sources Tab (#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 #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"
/>
2025-07-23 10:28:11 -04:00

535 lines
18 KiB
JavaScript

/**
* 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 {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
use,
} from 'react';
import {useSubscription} from '../hooks';
import {StoreContext} from '../context';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
import Toggle from '../Toggle';
import {SettingsContext} from '../Settings/SettingsContext';
import {
ComponentFilterDisplayName,
ComponentFilterElementType,
ComponentFilterHOC,
ComponentFilterLocation,
ComponentFilterEnvironmentName,
ElementTypeClass,
ElementTypeContext,
ElementTypeFunction,
ElementTypeForwardRef,
ElementTypeHostComponent,
ElementTypeMemo,
ElementTypeOtherOrUnknown,
ElementTypeProfiler,
ElementTypeSuspense,
ElementTypeActivity,
ElementTypeViewTransition,
} from 'react-devtools-shared/src/frontend/types';
import styles from './SettingsShared.css';
import type {
BooleanComponentFilter,
ComponentFilter,
ComponentFilterType,
ElementType,
ElementTypeComponentFilter,
RegExpComponentFilter,
EnvironmentNameComponentFilter,
} from 'react-devtools-shared/src/frontend/types';
import {isInternalFacebookBuild} from 'react-devtools-feature-flags';
export default function ComponentsSettings({
environmentNames,
}: {
environmentNames: Promise<Array<string>>,
}): React.Node {
const store = useContext(StoreContext);
const {parseHookNames, setParseHookNames} = useContext(SettingsContext);
const collapseNodesByDefaultSubscription = useMemo(
() => ({
getCurrentValue: () => store.collapseNodesByDefault,
subscribe: (callback: Function) => {
store.addListener('collapseNodesByDefault', callback);
return () => store.removeListener('collapseNodesByDefault', callback);
},
}),
[store],
);
const collapseNodesByDefault = useSubscription<boolean>(
collapseNodesByDefaultSubscription,
);
const updateCollapseNodesByDefault = useCallback(
({currentTarget}: $FlowFixMe) => {
store.collapseNodesByDefault = !currentTarget.checked;
},
[store],
);
const updateParseHookNames = useCallback(
({currentTarget}: $FlowFixMe) => {
setParseHookNames(currentTarget.checked);
},
[setParseHookNames],
);
const [componentFilters, setComponentFilters] = useState<
Array<ComponentFilter>,
>(() => [...store.componentFilters]);
const usedEnvironmentNames = use(environmentNames);
const resolvedEnvironmentNames = useMemo(() => {
const set = new Set(usedEnvironmentNames);
// If there are other filters already specified but are not currently
// on the page, we still allow them as options.
for (let i = 0; i < componentFilters.length; i++) {
const filter = componentFilters[i];
if (filter.type === ComponentFilterEnvironmentName) {
set.add(filter.value);
}
}
// Client is special and is always available as a default.
if (set.size > 0) {
// Only show any options at all if there's any other option already
// used by a filter or if any environments are used by the page.
// Note that "Client" can have been added above which would mean
// that we should show it as an option regardless if it's the only
// option.
set.add('Client');
}
return Array.from(set).sort();
}, [usedEnvironmentNames, componentFilters]);
const addFilter = useCallback(() => {
setComponentFilters(prevComponentFilters => {
return [
...prevComponentFilters,
{
type: ComponentFilterElementType,
value: ElementTypeHostComponent,
isEnabled: true,
},
];
});
}, []);
const changeFilterType = useCallback(
(componentFilter: ComponentFilter, type: ComponentFilterType) => {
setComponentFilters(prevComponentFilters => {
const cloned: Array<ComponentFilter> = [...prevComponentFilters];
const index = prevComponentFilters.indexOf(componentFilter);
if (index >= 0) {
if (type === ComponentFilterElementType) {
cloned[index] = {
type: ComponentFilterElementType,
isEnabled: componentFilter.isEnabled,
value: ElementTypeHostComponent,
};
} else if (type === ComponentFilterDisplayName) {
cloned[index] = {
type: ComponentFilterDisplayName,
isEnabled: componentFilter.isEnabled,
isValid: true,
value: '',
};
} else if (type === ComponentFilterLocation) {
cloned[index] = {
type: ComponentFilterLocation,
isEnabled: componentFilter.isEnabled,
isValid: true,
value: '',
};
} else if (type === ComponentFilterHOC) {
cloned[index] = {
type: ComponentFilterHOC,
isEnabled: componentFilter.isEnabled,
isValid: true,
};
} else if (type === ComponentFilterEnvironmentName) {
cloned[index] = {
type: ComponentFilterEnvironmentName,
isEnabled: componentFilter.isEnabled,
isValid: true,
value: 'Client',
};
}
}
return cloned;
});
},
[],
);
const updateFilterValueElementType = useCallback(
(componentFilter: ComponentFilter, value: ElementType) => {
if (componentFilter.type !== ComponentFilterElementType) {
throw Error('Invalid value for element type filter');
}
setComponentFilters(prevComponentFilters => {
const cloned: Array<ComponentFilter> = [...prevComponentFilters];
if (componentFilter.type === ComponentFilterElementType) {
const index = prevComponentFilters.indexOf(componentFilter);
if (index >= 0) {
cloned[index] = {
...componentFilter,
value,
};
}
}
return cloned;
});
},
[],
);
const updateFilterValueRegExp = useCallback(
(componentFilter: ComponentFilter, value: string) => {
if (componentFilter.type === ComponentFilterElementType) {
throw Error('Invalid value for element type filter');
}
setComponentFilters(prevComponentFilters => {
const cloned: Array<ComponentFilter> = [...prevComponentFilters];
if (
componentFilter.type === ComponentFilterDisplayName ||
componentFilter.type === ComponentFilterLocation
) {
const index = prevComponentFilters.indexOf(componentFilter);
if (index >= 0) {
let isValid = true;
try {
new RegExp(value); // eslint-disable-line no-new
} catch (error) {
isValid = false;
}
cloned[index] = {
...componentFilter,
isValid,
value,
};
}
}
return cloned;
});
},
[],
);
const updateFilterValueEnvironmentName = useCallback(
(componentFilter: ComponentFilter, value: string) => {
if (componentFilter.type !== ComponentFilterEnvironmentName) {
throw Error('Invalid value for environment name filter');
}
setComponentFilters(prevComponentFilters => {
const cloned: Array<ComponentFilter> = [...prevComponentFilters];
if (componentFilter.type === ComponentFilterEnvironmentName) {
const index = prevComponentFilters.indexOf(componentFilter);
if (index >= 0) {
cloned[index] = {
...componentFilter,
value,
};
}
}
return cloned;
});
},
[],
);
const removeFilter = useCallback((index: number) => {
setComponentFilters(prevComponentFilters => {
const cloned: Array<ComponentFilter> = [...prevComponentFilters];
cloned.splice(index, 1);
return cloned;
});
}, []);
const removeAllFilter = () => {
setComponentFilters([]);
};
const toggleFilterIsEnabled = useCallback(
(componentFilter: ComponentFilter, isEnabled: boolean) => {
setComponentFilters(prevComponentFilters => {
const cloned: Array<ComponentFilter> = [...prevComponentFilters];
const index = prevComponentFilters.indexOf(componentFilter);
if (index >= 0) {
if (componentFilter.type === ComponentFilterElementType) {
cloned[index] = {
...((cloned[index]: any): ElementTypeComponentFilter),
isEnabled,
};
} else if (
componentFilter.type === ComponentFilterDisplayName ||
componentFilter.type === ComponentFilterLocation
) {
cloned[index] = {
...((cloned[index]: any): RegExpComponentFilter),
isEnabled,
};
} else if (componentFilter.type === ComponentFilterHOC) {
cloned[index] = {
...((cloned[index]: any): BooleanComponentFilter),
isEnabled,
};
} else if (componentFilter.type === ComponentFilterEnvironmentName) {
cloned[index] = {
...((cloned[index]: any): EnvironmentNameComponentFilter),
isEnabled,
};
}
}
return cloned;
});
},
[],
);
// Filter updates are expensive to apply (since they impact the entire tree).
// Only apply them on unmount.
// The Store will avoid doing any expensive work unless they've changed.
// We just want to batch the work in the event that they do change.
const componentFiltersRef = useRef<Array<ComponentFilter>>(componentFilters);
useEffect(() => {
componentFiltersRef.current = componentFilters;
return () => {};
}, [componentFilters]);
useEffect(
() => () => {
store.componentFilters = [...componentFiltersRef.current];
},
[store],
);
return (
<div className={styles.SettingList}>
<div className={styles.SettingWrapper}>
<label className={styles.SettingRow}>
<input
type="checkbox"
checked={!collapseNodesByDefault}
onChange={updateCollapseNodesByDefault}
className={styles.SettingRowCheckbox}
/>
Expand component tree by default
</label>
</div>
<div className={styles.SettingWrapper}>
<label className={styles.SettingRow}>
<input
type="checkbox"
checked={parseHookNames}
onChange={updateParseHookNames}
className={styles.SettingRowCheckbox}
/>
Always parse hook names from source&nbsp;
<span className={styles.Warning}>(may be slow)</span>
</label>
</div>
<div className={styles.Header}>Hide components where...</div>
<table className={styles.Table}>
<tbody>
{componentFilters.length === 0 && (
<tr className={styles.TableRow}>
<td className={styles.NoFiltersCell}>
No filters have been added.
</td>
</tr>
)}
{componentFilters.map((componentFilter, index) => (
<tr className={styles.TableRow} key={index}>
<td className={styles.TableCell}>
<Toggle
className={
componentFilter.isValid !== false
? ''
: styles.InvalidRegExp
}
isChecked={componentFilter.isEnabled}
onChange={isEnabled =>
toggleFilterIsEnabled(componentFilter, isEnabled)
}
title={
componentFilter.isValid === false
? 'Filter invalid'
: componentFilter.isEnabled
? 'Filter enabled'
: 'Filter disabled'
}>
<ToggleIcon
isEnabled={componentFilter.isEnabled}
isValid={
componentFilter.isValid == null ||
componentFilter.isValid === true
}
/>
</Toggle>
</td>
<td className={styles.TableCell}>
<select
value={componentFilter.type}
onChange={({currentTarget}) =>
changeFilterType(
componentFilter,
((parseInt(
currentTarget.value,
10,
): any): ComponentFilterType),
)
}>
{/* TODO: currently disabled, need find a new way of doing this
<option value={ComponentFilterLocation}>location</option>
*/}
<option value={ComponentFilterDisplayName}>name</option>
<option value={ComponentFilterElementType}>type</option>
<option value={ComponentFilterHOC}>hoc</option>
{resolvedEnvironmentNames.length > 0 && (
<option value={ComponentFilterEnvironmentName}>
environment
</option>
)}
</select>
</td>
<td className={styles.TableCell}>
{(componentFilter.type === ComponentFilterElementType ||
componentFilter.type === ComponentFilterEnvironmentName) &&
'equals'}
{(componentFilter.type === ComponentFilterLocation ||
componentFilter.type === ComponentFilterDisplayName) &&
'matches'}
</td>
<td className={styles.TableCell}>
{componentFilter.type === ComponentFilterElementType && (
<select
value={componentFilter.value}
onChange={({currentTarget}) =>
updateFilterValueElementType(
componentFilter,
((parseInt(currentTarget.value, 10): any): ElementType),
)
}>
{isInternalFacebookBuild && (
<option value={ElementTypeActivity}>activity</option>
)}
<option value={ElementTypeClass}>class</option>
<option value={ElementTypeContext}>context</option>
<option value={ElementTypeFunction}>function</option>
<option value={ElementTypeForwardRef}>forward ref</option>
<option value={ElementTypeHostComponent}>
{__IS_NATIVE__
? 'host components (e.g. <RCTText>)'
: 'dom nodes (e.g. <div>)'}
</option>
<option value={ElementTypeMemo}>memo</option>
<option value={ElementTypeOtherOrUnknown}>other</option>
<option value={ElementTypeProfiler}>profiler</option>
<option value={ElementTypeSuspense}>suspense</option>
{isInternalFacebookBuild && (
<option value={ElementTypeViewTransition}>
view transition
</option>
)}
</select>
)}
{(componentFilter.type === ComponentFilterLocation ||
componentFilter.type === ComponentFilterDisplayName) && (
<input
className={styles.Input}
type="text"
placeholder="Regular expression"
onChange={({currentTarget}) =>
updateFilterValueRegExp(
componentFilter,
currentTarget.value,
)
}
value={componentFilter.value}
/>
)}
{componentFilter.type === ComponentFilterEnvironmentName && (
<select
value={componentFilter.value}
onChange={({currentTarget}) =>
updateFilterValueEnvironmentName(
componentFilter,
currentTarget.value,
)
}>
{resolvedEnvironmentNames.map(name => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
)}
</td>
<td className={styles.TableCell}>
<Button
onClick={() => removeFilter(index)}
title="Delete filter">
<ButtonIcon type="delete" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
<Button onClick={addFilter} title="Add filter">
<ButtonIcon className={styles.ButtonIcon} type="add" />
Add filter
</Button>
{componentFilters.length > 0 && (
<Button onClick={removeAllFilter} title="Delete all filters">
<ButtonIcon className={styles.ButtonIcon} type="delete" />
Delete all filters
</Button>
)}
</div>
);
}
type ToggleIconProps = {
isEnabled: boolean,
isValid: boolean,
};
function ToggleIcon({isEnabled, isValid}: ToggleIconProps) {
let className;
if (isValid) {
className = isEnabled ? styles.ToggleOn : styles.ToggleOff;
} else {
className = isEnabled ? styles.ToggleOnInvalid : styles.ToggleOffInvalid;
}
return (
<div className={className}>
<div
className={isEnabled ? styles.ToggleInsideOn : styles.ToggleInsideOff}
/>
</div>
);
}