Files
react/packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js
T
Sebastian Markbåge 36c04348d7 [DevTools] Make Functions Clickable to Jump to Definition (#30769)
Currently you can jump to definition of a function by right clicking
through the context menu. However, it's pretty difficult to discover.
This makes the functions clickable to jump to definition - like links.

This uses the same styling as we do for links (which are btw only
clickable if they're not editable). Including cursor: pointer.

I added a background on hover which follows the same pattern as the
owners list.

I also dropped the ƒ prefix when displaying functions. This is a cute
short cut and there's precedence in how Chrome prints functions in the
console *if* the function's toString would've had a function prefix like
if it was a function declaration or expression. It does not do this for
arrow functions or object methods.

Elsewhere in the JS ecosystem this isn't really used anywhere. It
invites more questions than it answers.

The parenthesis and curlies are enough. There's no ambiguity here since
strings have quotations. It looks better with just its object method
form. Keeping it simple seems best. To my eyes this flows better because
I'm used to looking at function syntax but not weird "f"s.

Before:

<img width="433" alt="Screenshot 2024-08-20 at 11 55 09 PM"
src="https://github.com/user-attachments/assets/9dd50da6-598f-4291-9e24-1cdc7200dc9e">


After:
<img width="388" alt="Screenshot 2024-08-20 at 11 46 01 PM"
src="https://github.com/user-attachments/assets/dd988e14-412e-4deb-8c8c-26a54be8331f">


After (Hover):
<img width="389" alt="Screenshot 2024-08-20 at 11 46 31 PM"
src="https://github.com/user-attachments/assets/6fb4ebed-5dc1-448a-8e4d-b6d4f3903329">
2024-08-22 12:35:49 -04:00

574 lines
17 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 {useTransition, useContext, useRef, useState, useMemo} from 'react';
import {OptionsContext} from '../context';
import EditableName from './EditableName';
import EditableValue from './EditableValue';
import NewArrayValue from './NewArrayValue';
import NewKeyValue from './NewKeyValue';
import LoadingAnimation from './LoadingAnimation';
import ExpandCollapseToggle from './ExpandCollapseToggle';
import {alphaSortEntries, getMetaValueLabel} from '../utils';
import {meta} from '../../../hydration';
import Store from '../../store';
import {parseHookPathForEdit} from './utils';
import styles from './KeyValue.css';
import Button from 'react-devtools-shared/src/devtools/views/Button';
import ButtonIcon from 'react-devtools-shared/src/devtools/views/ButtonIcon';
import isArray from 'react-devtools-shared/src/isArray';
import {InspectedElementContext} from './InspectedElementContext';
import {PROTOCOLS_SUPPORTED_AS_LINKS_IN_KEY_VALUE} from './constants';
import KeyValueContextMenuContainer from './KeyValueContextMenuContainer';
import {ContextMenuContext} from '../context';
import type {ContextMenuContextType} from '../context';
import type {InspectedElement} from 'react-devtools-shared/src/frontend/types';
import type {Element} from 'react-devtools-shared/src/frontend/types';
import type {Element as ReactElement} from 'react';
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
// $FlowFixMe[method-unbinding]
const hasOwnProperty = Object.prototype.hasOwnProperty;
type Type = 'props' | 'state' | 'context' | 'hooks';
type KeyValueProps = {
alphaSort: boolean,
bridge: FrontendBridge,
canDeletePaths: boolean,
canEditValues: boolean,
canRenamePaths: boolean,
canRenamePathsAtDepth?: (depth: number) => boolean,
depth: number,
element: Element,
hidden: boolean,
hookID?: ?number,
hookName?: ?string,
inspectedElement: InspectedElement,
isDirectChildOfAnArray?: boolean,
name: string,
path: Array<any>,
pathRoot: Type,
store: Store,
value: any,
};
export default function KeyValue({
alphaSort,
bridge,
canDeletePaths,
canEditValues,
canRenamePaths,
canRenamePathsAtDepth,
depth,
element,
inspectedElement,
isDirectChildOfAnArray,
hidden,
hookID,
hookName,
name,
path,
pathRoot,
store,
value,
}: KeyValueProps): React.Node {
const {readOnly: readOnlyGlobalFlag} = useContext(OptionsContext);
canDeletePaths = !readOnlyGlobalFlag && canDeletePaths;
canEditValues = !readOnlyGlobalFlag && canEditValues;
canRenamePaths = !readOnlyGlobalFlag && canRenamePaths;
const {id} = inspectedElement;
const fullPath = useMemo(() => [pathRoot, ...path], [pathRoot, path]);
const [isOpen, setIsOpen] = useState<boolean>(false);
const contextMenuTriggerRef = useRef(null);
const {inspectPaths} = useContext(InspectedElementContext);
const {viewAttributeSourceFunction} =
useContext<ContextMenuContextType>(ContextMenuContext);
let isInspectable = false;
let isReadOnlyBasedOnMetadata = false;
if (value !== null && typeof value === 'object') {
isInspectable = value[meta.inspectable] && value[meta.size] !== 0;
isReadOnlyBasedOnMetadata = value[meta.readonly];
}
const [isInspectPathsPending, startInspectPathsTransition] = useTransition();
const toggleIsOpen = () => {
if (isOpen) {
setIsOpen(false);
} else {
setIsOpen(true);
if (isInspectable) {
startInspectPathsTransition(() => {
inspectPaths([pathRoot, ...path]);
});
}
}
};
const dataType = typeof value;
const isSimpleType =
dataType === 'number' ||
dataType === 'string' ||
dataType === 'boolean' ||
value == null;
const pathType =
value !== null &&
typeof value === 'object' &&
hasOwnProperty.call(value, meta.type)
? value[meta.type]
: typeof value;
const pathIsFunction = pathType === 'function';
const style = {
paddingLeft: `${(depth - 1) * 0.75}rem`,
};
const overrideValue = (newPath: Array<string | number>, newValue: any) => {
if (hookID != null) {
newPath = parseHookPathForEdit(newPath);
}
const rendererID = store.getRendererIDForElement(id);
if (rendererID !== null) {
bridge.send('overrideValueAtPath', {
hookID,
id,
path: newPath,
rendererID,
type: pathRoot,
value: newValue,
});
}
};
const deletePath = (pathToDelete: Array<string | number>) => {
if (hookID != null) {
pathToDelete = parseHookPathForEdit(pathToDelete);
}
const rendererID = store.getRendererIDForElement(id);
if (rendererID !== null) {
bridge.send('deletePath', {
hookID,
id,
path: pathToDelete,
rendererID,
type: pathRoot,
});
}
};
const renamePath = (
oldPath: Array<string | number>,
newPath: Array<string | number>,
) => {
if (newPath[newPath.length - 1] === '') {
// Deleting the key suggests an intent to delete the whole path.
if (canDeletePaths) {
deletePath(oldPath);
}
} else {
if (hookID != null) {
oldPath = parseHookPathForEdit(oldPath);
newPath = parseHookPathForEdit(newPath);
}
const rendererID = store.getRendererIDForElement(id);
if (rendererID !== null) {
bridge.send('renamePath', {
hookID,
id,
newPath,
oldPath,
rendererID,
type: pathRoot,
});
}
}
};
// TRICKY This is a bit of a hack to account for context and hooks.
// In these cases, paths can be renamed but only at certain depths.
// The special "value" wrapper for context shouldn't be editable.
// Only certain types of hooks should be editable.
let canRenameTheCurrentPath = canRenamePaths;
if (canRenameTheCurrentPath && typeof canRenamePathsAtDepth === 'function') {
canRenameTheCurrentPath = canRenamePathsAtDepth(depth);
}
let renderedName;
if (isDirectChildOfAnArray) {
if (canDeletePaths) {
renderedName = (
<DeleteToggle name={name} deletePath={deletePath} path={path} />
);
} else {
renderedName = (
<span className={styles.Name}>
{name}
{!!hookName && <span className={styles.HookName}>({hookName})</span>}
</span>
);
}
} else if (canRenameTheCurrentPath) {
renderedName = (
<EditableName
allowEmpty={canDeletePaths}
className={styles.EditableName}
initialValue={name}
overrideName={renamePath}
path={path}
/>
);
} else {
renderedName = (
<span className={styles.Name} data-testname="NonEditableName">
{name}
{!!hookName && <span className={styles.HookName}>({hookName})</span>}
</span>
);
}
let children = null;
if (isSimpleType) {
let displayValue = value;
if (dataType === 'string') {
displayValue = `"${value}"`;
} else if (dataType === 'boolean') {
displayValue = value ? 'true' : 'false';
} else if (value === null) {
displayValue = 'null';
} else if (value === undefined) {
displayValue = 'undefined';
} else if (isNaN(value)) {
displayValue = 'NaN';
}
let shouldDisplayValueAsLink = false;
if (
dataType === 'string' &&
PROTOCOLS_SUPPORTED_AS_LINKS_IN_KEY_VALUE.some(protocolPrefix =>
value.startsWith(protocolPrefix),
)
) {
shouldDisplayValueAsLink = true;
}
children = (
<KeyValueContextMenuContainer
key="root"
anchorElementRef={contextMenuTriggerRef}
attributeSourceCanBeInspected={false}
canBeCopiedToClipboard={true}
store={store}
bridge={bridge}
id={id}
path={fullPath}>
<div
data-testname="KeyValue"
className={styles.Item}
hidden={hidden}
ref={contextMenuTriggerRef}
style={style}>
<div className={styles.ExpandCollapseToggleSpacer} />
{renderedName}
<div className={styles.AfterName}>:</div>
{canEditValues ? (
<EditableValue
overrideValue={overrideValue}
path={path}
value={value}
/>
) : shouldDisplayValueAsLink ? (
<a
className={styles.Link}
href={value}
target="_blank"
rel="noopener noreferrer">
{displayValue}
</a>
) : (
<span className={styles.Value} data-testname="NonEditableValue">
{displayValue}
</span>
)}
</div>
</KeyValueContextMenuContainer>
);
} else if (pathIsFunction && viewAttributeSourceFunction != null) {
children = (
<KeyValueContextMenuContainer
key="root"
anchorElementRef={contextMenuTriggerRef}
attributeSourceCanBeInspected={true}
canBeCopiedToClipboard={false}
store={store}
bridge={bridge}
id={id}
path={fullPath}>
<div
data-testname="KeyValue"
className={styles.Item}
hidden={hidden}
ref={contextMenuTriggerRef}
style={style}>
<div className={styles.ExpandCollapseToggleSpacer} />
{renderedName}
<div className={styles.AfterName}>:</div>
<span
className={styles.Link}
onClick={() => {
viewAttributeSourceFunction(id, fullPath);
}}>
{getMetaValueLabel(value)}
</span>
</div>
</KeyValueContextMenuContainer>
);
} else if (
hasOwnProperty.call(value, meta.type) &&
!hasOwnProperty.call(value, meta.unserializable)
) {
children = (
<KeyValueContextMenuContainer
key="root"
anchorElementRef={contextMenuTriggerRef}
attributeSourceCanBeInspected={false}
canBeCopiedToClipboard={true}
store={store}
bridge={bridge}
id={id}
path={fullPath}>
<div
data-testname="KeyValue"
className={styles.Item}
hidden={hidden}
ref={contextMenuTriggerRef}
style={style}>
{isInspectable ? (
<ExpandCollapseToggle isOpen={isOpen} setIsOpen={toggleIsOpen} />
) : (
<div className={styles.ExpandCollapseToggleSpacer} />
)}
{renderedName}
<div className={styles.AfterName}>:</div>
<span
className={styles.Value}
onClick={isInspectable ? toggleIsOpen : undefined}>
{getMetaValueLabel(value)}
</span>
</div>
</KeyValueContextMenuContainer>
);
if (isInspectPathsPending) {
children = (
<>
{children}
<div className={styles.Item} style={style}>
<div className={styles.ExpandCollapseToggleSpacer} />
<LoadingAnimation />
</div>
</>
);
}
} else {
if (isArray(value)) {
const hasChildren = value.length > 0 || canEditValues;
const displayName = getMetaValueLabel(value);
children = value.map((innerValue, index) => (
<KeyValue
key={index}
alphaSort={alphaSort}
bridge={bridge}
canDeletePaths={canDeletePaths && !isReadOnlyBasedOnMetadata}
canEditValues={canEditValues && !isReadOnlyBasedOnMetadata}
canRenamePaths={canRenamePaths && !isReadOnlyBasedOnMetadata}
canRenamePathsAtDepth={canRenamePathsAtDepth}
depth={depth + 1}
element={element}
hookID={hookID}
inspectedElement={inspectedElement}
isDirectChildOfAnArray={true}
hidden={hidden || !isOpen}
name={index}
path={path.concat(index)}
pathRoot={pathRoot}
store={store}
value={value[index]}
/>
));
if (canEditValues && !isReadOnlyBasedOnMetadata) {
children.push(
<NewArrayValue
key="NewKeyValue"
bridge={bridge}
depth={depth + 1}
hidden={hidden || !isOpen}
hookID={hookID}
index={value.length}
element={element}
inspectedElement={inspectedElement}
path={path}
store={store}
type={pathRoot}
/>,
);
}
children.unshift(
<KeyValueContextMenuContainer
key={`${depth}-root`}
anchorElementRef={contextMenuTriggerRef}
attributeSourceCanBeInspected={pathIsFunction}
canBeCopiedToClipboard={!pathIsFunction}
store={store}
bridge={bridge}
id={id}
path={fullPath}>
<div
data-testname="KeyValue"
className={styles.Item}
hidden={hidden}
ref={contextMenuTriggerRef}
style={style}>
{hasChildren ? (
<ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} />
) : (
<div className={styles.ExpandCollapseToggleSpacer} />
)}
{renderedName}
<div className={styles.AfterName}>:</div>
<span
className={styles.Value}
onClick={hasChildren ? toggleIsOpen : undefined}>
{displayName}
</span>
</div>
</KeyValueContextMenuContainer>,
);
} else {
// TRICKY
// It's important to use Object.entries() rather than Object.keys()
// because of the hidden meta Symbols used for hydration and unserializable values.
const entries = Object.entries(value);
if (alphaSort) {
entries.sort(alphaSortEntries);
}
const hasChildren = entries.length > 0 || canEditValues;
const displayName = getMetaValueLabel(value);
children = entries.map(([key, keyValue]): ReactElement<any> => (
<KeyValue
key={key}
alphaSort={alphaSort}
bridge={bridge}
canDeletePaths={canDeletePaths && !isReadOnlyBasedOnMetadata}
canEditValues={canEditValues && !isReadOnlyBasedOnMetadata}
canRenamePaths={canRenamePaths && !isReadOnlyBasedOnMetadata}
canRenamePathsAtDepth={canRenamePathsAtDepth}
depth={depth + 1}
element={element}
hookID={hookID}
inspectedElement={inspectedElement}
hidden={hidden || !isOpen}
name={key}
path={path.concat(key)}
pathRoot={pathRoot}
store={store}
value={keyValue}
/>
));
if (canEditValues && !isReadOnlyBasedOnMetadata) {
children.push(
<NewKeyValue
key="NewKeyValue"
bridge={bridge}
depth={depth + 1}
element={element}
hidden={hidden || !isOpen}
hookID={hookID}
inspectedElement={inspectedElement}
path={path}
store={store}
type={pathRoot}
/>,
);
}
children.unshift(
<KeyValueContextMenuContainer
key={`${depth}-root`}
anchorElementRef={contextMenuTriggerRef}
attributeSourceCanBeInspected={pathIsFunction}
canBeCopiedToClipboard={!pathIsFunction}
store={store}
bridge={bridge}
id={id}
path={fullPath}>
<div
data-testname="KeyValue"
className={styles.Item}
hidden={hidden}
ref={contextMenuTriggerRef}
style={style}>
{hasChildren ? (
<ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} />
) : (
<div className={styles.ExpandCollapseToggleSpacer} />
)}
{renderedName}
<div className={styles.AfterName}>:</div>
<span
className={styles.Value}
onClick={hasChildren ? toggleIsOpen : undefined}>
{displayName}
</span>
</div>
</KeyValueContextMenuContainer>,
);
}
}
return children;
}
// $FlowFixMe[missing-local-annot]
function DeleteToggle({deletePath, name, path}) {
// $FlowFixMe[missing-local-annot]
const handleClick = event => {
event.stopPropagation();
deletePath(path);
};
return (
<>
<Button
className={styles.DeleteArrayItemButton}
onClick={handleClick}
title="Delete entry">
<ButtonIcon type="delete" />
</Button>
<span className={styles.Name}>{name}</span>
</>
);
}