diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /devtools/client/shared/components/reps | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/shared/components/reps')
48 files changed, 6283 insertions, 0 deletions
diff --git a/devtools/client/shared/components/reps/images/input.svg b/devtools/client/shared/components/reps/images/input.svg new file mode 100644 index 0000000000..830b651e9e --- /dev/null +++ b/devtools/client/shared/components/reps/images/input.svg @@ -0,0 +1,7 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="context-fill #0b0b0b"> + <path d="M11.04 5.46L7.29 1.71a.75.75 0 0 0-1.06 1.06L9.45 6 6.23 9.21a.75.75 0 1 0 1.06 1.06l3.75-3.75c.3-.3.3-.77 0-1.06z"/> + <path d="M6.04 5.46L2.29 1.71a.75.75 0 0 0-1.06 1.06L4.45 6 1.23 9.21a.75.75 0 1 0 1.06 1.06l3.75-3.75c.3-.3.3-.77 0-1.06z"/> +</svg>
\ No newline at end of file diff --git a/devtools/client/shared/components/reps/images/jump-definition.svg b/devtools/client/shared/components/reps/images/jump-definition.svg new file mode 100644 index 0000000000..9ac071523d --- /dev/null +++ b/devtools/client/shared/components/reps/images/jump-definition.svg @@ -0,0 +1,8 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" stroke="context-stroke" fill="none" stroke-linecap="round"> + <path d="M5.5 3.5l2 2M5.5 7.5l2-2"/> + <path d="M7 5.5H4.006c-1.012 0-1.995 1.017-2.011 2.024-.005.023-.005 1.347 0 3.971" stroke-linejoin="round"/> + <path d="M10.5 5.5h4M9.5 3.5h5M9.5 7.5h5"/> +</svg>
\ No newline at end of file diff --git a/devtools/client/shared/components/reps/images/open-a11y.svg b/devtools/client/shared/components/reps/images/open-a11y.svg new file mode 100644 index 0000000000..cba2ab93c9 --- /dev/null +++ b/devtools/client/shared/components/reps/images/open-a11y.svg @@ -0,0 +1,10 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15" fill="context-fill #0C0C0D"> + <path d="M9.5 2.5C9.5 3.60457 8.60457 4.5 7.5 4.5C6.39543 4.5 5.5 3.60457 5.5 2.5C5.5 1.39543 6.39543 0.5 7.5 0.5C8.60457 0.5 9.5 1.39543 9.5 2.5Z"/> + <path d="M1.5 6C1.5 5.44772 1.94772 5 2.5 5H12.5C13.0523 5 13.5 5.44772 13.5 6C13.5 6.55228 13.0523 7 12.5 7H2.5C1.94772 7 1.5 6.55228 1.5 6Z"/> + <path d="M6 5C6.55228 5 7 5.44772 7 6L7 13C7 13.5523 6.55228 14 6 14C5.44771 14 5 13.5523 5 13L5 6C5 5.44772 5.44772 5 6 5Z"/> + <path d="M9 5C9.55228 5 10 5.44772 10 6V13C10 13.5523 9.55228 14 9 14C8.44771 14 8 13.5523 8 13L8 6C8 5.44772 8.44772 5 9 5Z"/> + <path d="M5 7H10V10.03H5V7Z"/> +</svg> diff --git a/devtools/client/shared/components/reps/images/open-inspector.svg b/devtools/client/shared/components/reps/images/open-inspector.svg new file mode 100644 index 0000000000..9e8a277e7c --- /dev/null +++ b/devtools/client/shared/components/reps/images/open-inspector.svg @@ -0,0 +1,6 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15" fill="context-fill"> + <path d="M7 3H5a2 2 0 0 0-2 2v2H1.5a.5.5 0 0 0 0 1H3v2c0 1.1.9 2 2 2h2v1.5a.5.5 0 0 0 1 0V12h2a2 2 0 0 0 2-2V8h1.5a.5.5 0 0 0 0-1H12V5a2 2 0 0 0-2-2H8V1.5a.5.5 0 0 0-1 0V3zM5 5h5v5H5V5z"/> +</svg> diff --git a/devtools/client/shared/components/reps/index.js b/devtools/client/shared/components/reps/index.js new file mode 100644 index 0000000000..e99e642c38 --- /dev/null +++ b/devtools/client/shared/components/reps/index.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +const { + MODE, +} = require("devtools/client/shared/components/reps/reps/constants"); +const { + REPS, + getRep, +} = require("devtools/client/shared/components/reps/reps/rep"); +const objectInspector = require("devtools/client/shared/components/object-inspector/index"); + +const { + parseURLEncodedText, + parseURLParams, + maybeEscapePropertyName, + getGripPreviewItems, +} = require("devtools/client/shared/components/reps/reps/rep-utils"); + +module.exports = { + REPS, + getRep, + MODE, + maybeEscapePropertyName, + parseURLEncodedText, + parseURLParams, + getGripPreviewItems, + objectInspector, +}; diff --git a/devtools/client/shared/components/reps/moz.build b/devtools/client/shared/components/reps/moz.build new file mode 100644 index 0000000000..058e8046a7 --- /dev/null +++ b/devtools/client/shared/components/reps/moz.build @@ -0,0 +1,14 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "reps", + "shared", +] + +DevToolsModules( + "index.js", +) diff --git a/devtools/client/shared/components/reps/reps.css b/devtools/client/shared/components/reps/reps.css new file mode 100644 index 0000000000..da45f6d078 --- /dev/null +++ b/devtools/client/shared/components/reps/reps.css @@ -0,0 +1,394 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +.theme-dark, +.theme-light { + --number-color: var(--theme-highlight-green); + --string-color: var(--theme-highlight-red); + --null-color: var(--theme-comment); + --object-color: var(--theme-highlight-blue); + --caption-color: var(--theme-highlight-blue); + --location-color: var(--theme-comment); + --source-link-color: var(--theme-highlight-blue); + --node-color: var(--theme-highlight-purple); + --reference-color: var(--theme-highlight-blue); + --comment-node-color: var(--theme-comment); +} + +/******************************************************************************/ + +.inline { + display: inline; + white-space: normal; +} + +.objectBox-object { + font-weight: bold; + color: var(--object-color); + white-space: pre-wrap; +} + +.objectBox-string, +.objectBox-symbol, +.objectBox-text, +.objectBox-textNode, +.objectBox-table { + white-space: pre-wrap; +} + +.objectBox * { + unicode-bidi: isolate; +} + +.objectBox-number, +.objectBox-styleRule, +.objectBox-element, +.objectBox-textNode, +.objectBox-array > .length { + color: var(--number-color); +} + +.objectBox-textNode, +.objectBox-string, +.objectBox-symbol { + color: var(--string-color); +} + +.objectBox-empty-string { + font-style: italic; +} + +.objectBox-string a { + word-break: break-all; +} + +.objectBox-string a, +.objectBox-string a:visited { + color: currentColor; + text-decoration: underline; + text-decoration-skip-ink: none; + font-style: italic; + cursor: pointer; +} + +/* Visually hide the middle of "cropped" url */ +.objectBox-string a .cropped-url-middle { + max-width: 0; + max-height: 0; + display: inline-block; + overflow: hidden; + vertical-align: bottom; +} + +.objectBox-string a .cropped-url-end::before { + content: "…"; +} + + +.objectBox-function, +.objectBox-profile { + color: var(--object-color); +} + +.objectBox-stackTrace.reps-custom-format, +.objectBox-stackTrace.reps-custom-format > .objectBox-string { + color: var(--error-color); +} + +.objectBox-stackTrace-grid { + display: inline-grid; + grid-template-columns: auto auto; + margin-top: 3px; +} + +.objectBox-stackTrace-fn { + color: var(--console-output-color); + padding-inline-start: 17px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-inline-end: 5px; +} + +.objectBox-stackTrace-location { + color: var(--frame-link-source, currentColor); + direction: rtl; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: end; +} + +.objectBox-stackTrace-location:hover { + text-decoration: underline; +} + +.objectBox-stackTrace-location { + cursor: pointer; +} + +.objectBox-Location, +.location { + color: var(--location-color); +} + +.objectBox-null, +.objectBox-undefined, +.objectBox-hint, +.objectBox-nan, +.logRowHint { + color: var(--null-color); +} + +.objectBox-sourceLink { + position: absolute; + right: 4px; + top: 2px; + padding-left: 8px; + font-weight: bold; + color: var(--source-link-color); +} + +.objectBox-failure { + color: var(--string-color); + border-width: 1px; + border-style: solid; + border-radius: 2px; + font-size: 0.8em; + padding: 0 2px; +} + +.objectBox-accessible.clickable, +.objectBox-node.clickable { + cursor: pointer; +} + +/* JsonML reps can be nested, though only the top-level rep needs layout + * adjustments to align it with the toggle arrow and fit its width to its + * contents. */ +.objectBox-jsonml-wrapper { + display: inline-flex; + flex-direction: column; + width: fit-content; + word-break: break-word; + line-height: normal; +} + +.objectBox-jsonml-wrapper[data-expandable="true"] { + cursor: default; +} + +.objectBox-jsonml-wrapper .jsonml-header-collapse-button { + margin: 0 4px 2px 0; + padding: 0; + vertical-align: middle; +} + +.objectBox-jsonml-wrapper .jsonml-header-collapse-button::before { + content: ""; + display: block; + width: 10px; + height: 10px; + background: url("chrome://devtools/skin/images/arrow.svg") no-repeat center; + background-size: 10px; + transform: rotate(-90deg); + transition: transform 125ms ease; + -moz-context-properties: fill; + fill: var(--theme-icon-dimmed-color); +} + +.objectBox-jsonml-wrapper .jsonml-header-collapse-button[aria-expanded="true"]::before { + transform: rotate(0deg); +} + +/******************************************************************************/ + +.objectBox-event, +.objectBox-eventLog, +.objectBox-regexp, +.objectBox-object { + color: var(--object-color); + white-space: pre-wrap; +} + +.objectBox .Date { + color: var(--string-color); + white-space: pre-wrap; +} + +/******************************************************************************/ + +.objectBox.theme-comment { + color: var(--comment-node-color); +} + +.accessible-role, +.tag-name { + color: var(--object-color); +} + +.attrName { + color: var(--string-color); +} + +.attrEqual, +.objectEqual { + color: var(--comment-node-color); +} + +.attrValue, +.attrValue.objectBox-string { + color: var(--node-color); +} + +.angleBracket { + color: var(--theme-body-color); +} + +/******************************************************************************/ +/* Length bubble for arraylikes and maplikes */ + +.objectLengthBubble { + color: var(--null-color); +} + +/******************************************************************************/ + +.objectLeftBrace, +.objectRightBrace, +.arrayLeftBracket, +.arrayRightBracket { + color: var(--object-color); +} + +/******************************************************************************/ +/* Cycle reference */ + +.objectBox-Reference { + font-weight: bold; + color: var(--reference-color); +} + +[class*="objectBox"] > .objectTitle { + color: var(--object-color); +} + +.caption { + color: var(--caption-color); +} + +/******************************************************************************/ +/* Themes */ + +.theme-dark .objectBox-null, +.theme-dark .objectBox-undefined, +.theme-light .objectBox-null, +.theme-light .objectBox-undefined { + font-style: normal; +} + +.theme-dark .objectBox-object, +.theme-light .objectBox-object { + font-weight: normal; + white-space: pre-wrap; +} + +.theme-dark .caption, +.theme-light .caption { + font-weight: normal; +} + +/******************************************************************************/ +/* Open DOMNode in inspector or Accessible in accessibility inspector button */ + +button.open-accessibility-inspector { + background: url("chrome://devtools/content/shared/components/reps/images/open-a11y.svg") + no-repeat; +} + +button.open-inspector { + background: url("chrome://devtools/content/shared/components/reps/images/open-inspector.svg") + no-repeat; +} + +button.highlight-node { + background: url("chrome://devtools/skin/images/highlight-selector.svg") + no-repeat; +} + + +button:is(.open-accessibility-inspector, .open-inspector, .highlight-node) { + display: inline-block; + vertical-align: top; + height: 15px; + width: 15px; + margin: 0 4px; + padding: 0; + border: none; + fill: var(--theme-icon-color); + cursor: pointer; + -moz-context-properties: fill; +} + +.objectBox-accessible:hover .open-accessibility-inspector, +.objectBox-node:hover .open-inspector, +.objectBox-textNode:hover .open-inspector, +.open-accessibility-inspector:hover, +.highlight-node:hover, +.open-inspector:hover { + fill: var(--theme-icon-checked-color); +} + +/******************************************************************************/ +/* Jump to definition button */ + +button.jump-definition { + display: inline-block; + height: 16px; + margin-left: 0.25em; + vertical-align: middle; + background: 0% 50% + url("chrome://devtools/content/shared/components/reps/images/jump-definition.svg") + no-repeat; + border-color: transparent; + stroke: var(--theme-icon-color); + -moz-context-properties: stroke; + cursor: pointer; +} + +.jump-definition:hover { + stroke: var(--theme-icon-checked-color); +} + +.tree-node.focused .jump-definition { + stroke: currentColor; +} + +/******************************************************************************/ +/* Invoke getter button */ + +button.invoke-getter { + mask: url(chrome://devtools/content/shared/components/reps/images/input.svg) + no-repeat; + display: inline-block; + background-color: var(--theme-icon-color); + height: 10px; + vertical-align: middle; + border: none; +} + +.invoke-getter:hover { + background-color: var(--theme-icon-checked-color); +} + +/******************************************************************************/ +/* "more…" ellipsis */ +.more-ellipsis { + color: var(--comment-node-color); +} + +/* function parameters */ +.objectBox-function .param { + color: var(--theme-highlight-red); +} diff --git a/devtools/client/shared/components/reps/reps/accessible.js b/devtools/client/shared/components/reps/reps/accessible.js new file mode 100644 index 0000000000..796a850161 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/accessible.js @@ -0,0 +1,197 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { + button, + span, + } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Utils + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + rep: StringRep, + } = require("devtools/client/shared/components/reps/reps/string"); + + /** + * Renders Accessible object. + */ + + Accessible.propTypes = { + object: PropTypes.object.isRequired, + inspectIconTitle: PropTypes.string, + nameMaxLength: PropTypes.number, + onAccessibleClick: PropTypes.func, + onAccessibleMouseOver: PropTypes.func, + onAccessibleMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + roleFirst: PropTypes.bool, + separatorText: PropTypes.string, + shouldRenderTooltip: PropTypes.bool, + }; + + function Accessible(props) { + const { + object, + inspectIconTitle, + nameMaxLength, + onAccessibleClick, + onInspectIconClick, + roleFirst, + separatorText, + } = props; + + const isInTree = object.preview && object.preview.isConnected === true; + + const config = getElementConfig({ ...props, isInTree }); + const elements = getElements( + object, + nameMaxLength, + roleFirst, + separatorText + ); + const inspectIcon = getInspectIcon({ + object, + onInspectIconClick, + inspectIconTitle, + onAccessibleClick, + isInTree, + }); + + return span(config, ...elements, inspectIcon); + } + + // Get React Config Obj + function getElementConfig(opts) { + const { + object, + isInTree, + onAccessibleClick, + onAccessibleMouseOver, + onAccessibleMouseOut, + shouldRenderTooltip, + roleFirst, + } = opts; + const { name, role } = object.preview; + + // Initiate config + const config = { + "data-link-actor-id": object.actor, + className: "objectBox objectBox-accessible", + }; + + if (isInTree) { + if (onAccessibleClick) { + Object.assign(config, { + onClick: _ => onAccessibleClick(object), + className: `${config.className} clickable`, + }); + } + + if (onAccessibleMouseOver) { + Object.assign(config, { + onMouseOver: _ => onAccessibleMouseOver(object), + }); + } + + if (onAccessibleMouseOut) { + Object.assign(config, { + onMouseOut: onAccessibleMouseOut, + }); + } + } + + // If tooltip, build tooltip + if (shouldRenderTooltip) { + let tooltip; + if (!name) { + tooltip = role; + } else { + const quotedName = `"${name}"`; + tooltip = `${roleFirst ? role : quotedName}: ${ + roleFirst ? quotedName : role + }`; + } + + config.title = tooltip; + } + + // Return config obj + return config; + } + + // Get Content Elements + function getElements( + grip, + nameMaxLength, + roleFirst = false, + separatorText = ": " + ) { + const { name, role } = grip.preview; + const elements = []; + + // If there's a `name` value in `grip.preview`, render it with the + // StringRep and push element into Elements array + + if (name) { + elements.push( + StringRep({ + className: "accessible-name", + object: name, + cropLimit: nameMaxLength, + }), + span({ className: "separator" }, separatorText) + ); + } + + elements.push(span({ className: "accessible-role" }, role)); + return roleFirst ? elements.reverse() : elements; + } + + // Get Icon + function getInspectIcon(opts) { + const { + object, + onInspectIconClick, + inspectIconTitle, + onAccessibleClick, + isInTree, + } = opts; + + if (!isInTree || !onInspectIconClick) { + return null; + } + + return button({ + className: "open-accessibility-inspector", + title: inspectIconTitle, + onClick: e => { + if (onAccessibleClick) { + e.stopPropagation(); + } + + onInspectIconClick(object, e); + }, + }); + } + + // Registration + function supportsObject(object) { + return ( + object?.preview && object.typeName && object.typeName === "accessible" + ); + } + + // Exports from this module + module.exports = { + rep: wrapRender(Accessible), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/accessor.js b/devtools/client/shared/components/reps/reps/accessor.js new file mode 100644 index 0000000000..b234d814b3 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/accessor.js @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const { + button, + span, + } = require("devtools/client/shared/vendor/react-dom-factories"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + /** + * Renders an object. An object is represented by a list of its + * properties enclosed in curly brackets. + */ + + Accessor.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + shouldRenderTooltip: PropTypes.bool, + }; + + function Accessor(props) { + const { + object, + evaluation, + onInvokeGetterButtonClick, + shouldRenderTooltip, + } = props; + + if (evaluation) { + const { + Rep, + Grip, + } = require("devtools/client/shared/components/reps/reps/rep"); + return span( + { + className: "objectBox objectBox-accessor objectTitle", + }, + Rep({ + ...props, + object: evaluation.getterValue, + mode: props.mode || MODE.TINY, + defaultRep: Grip, + }) + ); + } + + if (hasGetter(object) && onInvokeGetterButtonClick) { + return button({ + className: "invoke-getter", + title: "Invoke getter", + onClick: event => { + onInvokeGetterButtonClick(); + event.stopPropagation(); + }, + }); + } + + const accessors = []; + if (hasGetter(object)) { + accessors.push("Getter"); + } + + if (hasSetter(object)) { + accessors.push("Setter"); + } + + const accessorsString = accessors.join(" & "); + + return span( + { + className: "objectBox objectBox-accessor objectTitle", + title: shouldRenderTooltip ? accessorsString : null, + }, + accessorsString + ); + } + + function hasGetter(object) { + return object && object.get && object.get.type !== "undefined"; + } + + function hasSetter(object) { + return object && object.set && object.set.type !== "undefined"; + } + + function supportsObject(object) { + return hasGetter(object) || hasSetter(object); + } + + // Exports from this module + module.exports = { + rep: wrapRender(Accessor), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/array.js b/devtools/client/shared/components/reps/reps/array.js new file mode 100644 index 0000000000..f8797fa4d3 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/array.js @@ -0,0 +1,170 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + const ModePropType = PropTypes.oneOf(Object.values(MODE)); + + /** + * Renders an array. The array is enclosed by left and right bracket + * and the max number of rendered items depends on the current mode. + */ + + ArrayRep.propTypes = { + mode: ModePropType, + object: PropTypes.array.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function ArrayRep(props) { + const { object, mode = MODE.SHORT, shouldRenderTooltip = true } = props; + + let brackets; + let items; + const needSpace = function (space) { + return space ? { left: "[ ", right: " ]" } : { left: "[", right: "]" }; + }; + + if (mode === MODE.TINY) { + const isEmpty = object.length === 0; + if (isEmpty) { + items = []; + } else { + items = [ + span( + { + className: "more-ellipsis", + }, + "…" + ), + ]; + } + brackets = needSpace(false); + } else { + items = arrayIterator(props, object, maxLengthMap.get(mode)); + brackets = needSpace(!!items.length); + } + + return span( + { + className: "objectBox objectBox-array", + title: shouldRenderTooltip ? "Array" : null, + }, + span( + { + className: "arrayLeftBracket", + }, + brackets.left + ), + ...items, + span( + { + className: "arrayRightBracket", + }, + brackets.right + ) + ); + } + + function arrayIterator(props, array, max) { + const items = []; + + for (let i = 0; i < array.length && i < max; i++) { + const config = { + mode: MODE.TINY, + delim: i == array.length - 1 ? "" : ", ", + }; + let item; + + try { + item = ItemRep({ + ...props, + ...config, + object: array[i], + }); + } catch (exc) { + item = ItemRep({ + ...props, + ...config, + object: exc, + }); + } + items.push(item); + } + + if (array.length > max) { + items.push( + span( + { + className: "more-ellipsis", + }, + "…" + ) + ); + } + + return items; + } + + /** + * Renders array item. Individual values are separated by a comma. + */ + + ItemRep.propTypes = { + object: PropTypes.any.isRequired, + delim: PropTypes.string.isRequired, + mode: ModePropType, + }; + + function ItemRep(props) { + const { Rep } = require("devtools/client/shared/components/reps/reps/rep"); + + const { object, delim, mode } = props; + return span( + {}, + Rep({ + ...props, + object, + mode, + }), + delim + ); + } + + function getLength(object) { + return object.length; + } + + function supportsObject(object, noGrip = false) { + return ( + noGrip && + (Array.isArray(object) || + Object.prototype.toString.call(object) === "[object Arguments]") + ); + } + + const maxLengthMap = new Map(); + maxLengthMap.set(MODE.SHORT, 3); + maxLengthMap.set(MODE.LONG, 10); + + // Exports from this module + module.exports = { + rep: wrapRender(ArrayRep), + supportsObject, + maxLengthMap, + getLength, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/attribute.js b/devtools/client/shared/components/reps/reps/attribute.js new file mode 100644 index 0000000000..71975fe978 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/attribute.js @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + rep: StringRep, + } = require("devtools/client/shared/components/reps/reps/string"); + + /** + * Renders DOM attribute + */ + + Attribute.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function Attribute(props) { + const { object, shouldRenderTooltip } = props; + const value = object.preview.value; + const attrName = getTitle(object); + + const config = getElementConfig({ + attrName, + shouldRenderTooltip, + value, + object, + }); + + return span( + config, + span({ className: "attrName" }, attrName), + span({ className: "attrEqual" }, "="), + StringRep({ className: "attrValue", object: value }) + ); + } + + function getTitle(grip) { + return grip.preview.nodeName; + } + + function getElementConfig(opts) { + const { attrName, shouldRenderTooltip, value, object } = opts; + + return { + "data-link-actor-id": object.actor, + className: "objectBox-Attr", + title: shouldRenderTooltip ? `${attrName}="${value}"` : null, + }; + } + + // Registration + function supportsObject(grip, noGrip = false) { + return getGripType(grip, noGrip) == "Attr" && grip?.preview; + } + + module.exports = { + rep: wrapRender(Attribute), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/big-int.js b/devtools/client/shared/components/reps/reps/big-int.js new file mode 100644 index 0000000000..4bb7db507f --- /dev/null +++ b/devtools/client/shared/components/reps/reps/big-int.js @@ -0,0 +1,57 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders a BigInt Number + */ + + BigInt.propTypes = { + object: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.number, + PropTypes.bool, + ]).isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function BigInt(props) { + const { object, shouldRenderTooltip } = props; + const text = object.text; + const config = getElementConfig({ text, shouldRenderTooltip }); + + return span(config, `${text}n`); + } + + function getElementConfig(opts) { + const { text, shouldRenderTooltip } = opts; + + return { + className: "objectBox objectBox-number", + title: shouldRenderTooltip ? `${text}n` : null, + }; + } + function supportsObject(object, noGrip = false) { + return getGripType(object, noGrip) === "BigInt"; + } + + // Exports from this module + + module.exports = { + rep: wrapRender(BigInt), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/comment-node.js b/devtools/client/shared/components/reps/reps/comment-node.js new file mode 100644 index 0000000000..c84119a03a --- /dev/null +++ b/devtools/client/shared/components/reps/reps/comment-node.js @@ -0,0 +1,76 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + const { + cropString, + cropMultipleLines, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + const nodeConstants = require("devtools/client/shared/components/reps/shared/dom-node-constants"); + + /** + * Renders DOM comment node. + */ + + CommentNode.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + shouldRenderTooltip: PropTypes.bool, + }; + + function CommentNode(props) { + const { object, mode = MODE.SHORT, shouldRenderTooltip } = props; + + let { textContent } = object.preview; + if (mode === MODE.TINY) { + textContent = cropMultipleLines(textContent, 30); + } else if (mode === MODE.SHORT) { + textContent = cropString(textContent, 50); + } + + const config = getElementConfig({ + object, + textContent, + shouldRenderTooltip, + }); + + return span(config, `<!-- ${textContent} -->`); + } + + function getElementConfig(opts) { + const { object, shouldRenderTooltip } = opts; + + // Run textContent through cropString to sanitize + const uncroppedText = shouldRenderTooltip + ? cropString(object.preview.textContent) + : null; + + return { + className: "objectBox theme-comment", + "data-link-actor-id": object.actor, + title: shouldRenderTooltip ? `<!-- ${uncroppedText} -->` : null, + }; + } + + // Registration + function supportsObject(object) { + return object?.preview?.nodeType === nodeConstants.COMMENT_NODE; + } + + // Exports from this module + module.exports = { + rep: wrapRender(CommentNode), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/constants.js b/devtools/client/shared/components/reps/reps/constants.js new file mode 100644 index 0000000000..24965c3f5c --- /dev/null +++ b/devtools/client/shared/components/reps/reps/constants.js @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + module.exports = { + MODE: { + TINY: Symbol("TINY"), + SHORT: Symbol("SHORT"), + LONG: Symbol("LONG"), + }, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/custom-formatter.js b/devtools/client/shared/components/reps/reps/custom-formatter.js new file mode 100644 index 0000000000..469866f790 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/custom-formatter.js @@ -0,0 +1,256 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + // Dependencies + const { + Component, + createElement, + createFactory, + } = require("devtools/client/shared/vendor/react"); + const { + cleanupStyle, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const flags = require("resource://devtools/shared/flags.js"); + + const ALLOWED_TAGS = new Set([ + "span", + "div", + "ol", + "ul", + "li", + "table", + "tr", + "td", + ]); + + class CustomFormatter extends Component { + static get propTypes() { + return { + autoExpandDepth: PropTypes.number, + client: PropTypes.object, + createElement: PropTypes.func, + frame: PropTypes.object, + front: PropTypes.object, + object: PropTypes.object.isRequired, + }; + } + + constructor(props) { + super(props); + this.state = { open: false }; + this.toggleBody = this.toggleBody.bind(this); + } + + componentDidThrow(e) { + console.error("Error in CustomFormatter", e); + this.setState(state => ({ ...state, hasError: true })); + } + + async toggleBody(evt) { + evt.stopPropagation(); + + const open = !this.state.open; + if (open && !this.state.bodyJsonMl) { + let front = this.props.front; + if (!front && this.props.client?.createObjectFront) { + if (flags.testing && !this.props.frame) { + throw new Error("props.frame is mandatory"); + } + front = this.props.client.createObjectFront( + this.props.object, + this.props.frame + ); + } + if (!front) { + return; + } + + const response = await front.customFormatterBody(); + + const bodyJsonMl = renderJsonMl(response.customFormatterBody, { + ...this.props, + autoExpandDepth: this.props.autoExpandDepth + ? this.props.autoExpandDepth - 1 + : 0, + object: null, + }); + + this.setState(state => ({ + ...state, + bodyJsonMl, + open, + })); + } else { + this.setState(state => ({ + ...state, + bodyJsonMl: null, + open, + })); + } + } + + render() { + if (this.state && this.state.hasError) { + return createElement( + "span", + { + className: "objectBox objectBox-failure", + title: + "This object could not be rendered, " + + "please file a bug on bugzilla.mozilla.org", + }, + "Invalid custom formatter object" + ); + } + + const headerJsonMl = renderJsonMl(this.props.object.header, { + ...this.props, + open: this.state?.open, + }); + + return createElement( + "span", + { + className: "objectBox-jsonml-wrapper", + "data-expandable": this.props.object.hasBody, + "aria-expanded": this.state.open, + onClick: this.props.object.hasBody ? this.toggleBody : null, + }, + headerJsonMl, + this.state.bodyJsonMl + ? createElement( + "div", + { className: "objectBox-jsonml-body-wrapper" }, + this.state.bodyJsonMl + ) + : null + ); + } + } + + function renderJsonMl(jsonMl, props, index = 0) { + // The second item of the array can either be an object holding the attributes + // for the element or the first child element. Therefore, all array items after the + // first one are fetched together and split afterwards if needed. + let [tagName, ...attributesAndChildren] = jsonMl ?? []; + + if (!ALLOWED_TAGS.has(tagName)) { + tagName = "div"; + } + + const attributes = attributesAndChildren[0]; + const hasAttributes = + Object(attributes) === attributes && !Array.isArray(attributes); + const style = + hasAttributes && attributes?.style && props.createElement + ? cleanupStyle(attributes.style, props.createElement) + : null; + const children = attributesAndChildren; + if (hasAttributes) { + children.shift(); + } + + const childElements = []; + + if (props.object?.hasBody) { + childElements.push( + createElement("button", { + "aria-expanded": props.open, + className: `collapse-button jsonml-header-collapse-button${ + props.open ? " expanded" : "" + }`, + }) + ); + } + + if (Array.isArray(children)) { + children.forEach((child, childIndex) => { + let childElement; + // If the child is an array, it should be a JsonML item, so use this function to + // render them. + if (Array.isArray(child)) { + childElement = renderJsonMl( + child, + { ...props, object: null }, + childIndex + ); + } else if (typeof child === "object" && child !== null) { + // If we don't have an array, this means that we're probably dealing with + // a front or a grip. If the object has a `getGrip` function, call it to get the + // actual grip. + const gripOrPrimitive = + (child.typeName == "obj" || child.typeName == "string") && + typeof child?.getGrip == "function" + ? child.getGrip() + : child; + + // If the grip represents an object that was custom formatted, we should render + // it using this component. + if (supportsObject(gripOrPrimitive)) { + childElement = createElement(CustomFormatter, { + ...props, + object: gripOrPrimitive, + front: child && !!child.typeName ? child : null, + }); + } else { + // Here we have a non custom-formatted grip, so we let the ObjectInspector + // handles it. + const { + objectInspector, + MODE, + } = require("devtools/client/shared/components/reps/index"); + childElement = createElement(objectInspector.ObjectInspector, { + ...props, + mode: props.mode == MODE.LONG ? MODE.SHORT : MODE.TINY, + roots: [ + { + path: `${ + gripOrPrimitive?.actorID ?? gripOrPrimitive?.actor ?? null + }`, + contents: { + value: gripOrPrimitive, + front: child && !!child.typeName ? child : null, + }, + }, + ], + }); + } + } else { + // Here we have a primitive. We don't want to use Rep to render them as reps come + // with their own styling which might clash with the style defined in the JsonMl. + childElement = child; + } + childElements.push(childElement); + }); + } else { + childElements.push(children); + } + + return createElement( + tagName, + { + className: `objectBox objectBox-jsonml`, + key: `jsonml-${tagName}-${index}`, + style, + }, + childElements + ); + } + + function supportsObject(grip) { + return grip?.useCustomFormatter === true && Array.isArray(grip?.header); + } + + // Exports from this module + module.exports = { + rep: createFactory(CustomFormatter), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/date-time.js b/devtools/client/shared/components/reps/reps/date-time.js new file mode 100644 index 0000000000..36d15fcbaa --- /dev/null +++ b/devtools/client/shared/components/reps/reps/date-time.js @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Used to render JS built-in Date() object. + */ + + DateTime.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function DateTime(props) { + const { object: grip, shouldRenderTooltip } = props; + let date; + try { + const dateObject = new Date(grip.preview.timestamp); + // Calling `toISOString` will throw if the date is invalid, + // so we can render an `Invalid Date` element. + dateObject.toISOString(); + + const dateObjectString = dateObject.toString(); + + const config = getElementConfig({ + grip, + dateObjectString, + shouldRenderTooltip, + }); + + date = span( + config, + getTitle(grip), + span({ className: "Date" }, dateObjectString) + ); + } catch (e) { + date = span( + { + className: "objectBox", + title: shouldRenderTooltip ? "Invalid Date" : null, + }, + "Invalid Date" + ); + } + + return date; + } + + function getElementConfig(opts) { + const { grip, dateObjectString, shouldRenderTooltip } = opts; + + return { + "data-link-actor-id": grip.actor, + className: "objectBox", + title: shouldRenderTooltip ? `${grip.class} ${dateObjectString}` : null, + }; + } + + // getTitle() is used to render the `Date ` before the stringified date object, + // not to render the actual span "title". + + function getTitle(grip) { + return span( + { + className: "objectTitle", + }, + `${grip.class} ` + ); + } + + // Registration + function supportsObject(grip, noGrip = false) { + return getGripType(grip, noGrip) == "Date" && grip?.preview; + } + + // Exports from this module + module.exports = { + rep: wrapRender(DateTime), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/document-type.js b/devtools/client/shared/components/reps/reps/document-type.js new file mode 100644 index 0000000000..36442858fc --- /dev/null +++ b/devtools/client/shared/components/reps/reps/document-type.js @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders DOM documentType object. + */ + + DocumentType.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function DocumentType(props) { + const { object, shouldRenderTooltip } = props; + const name = + object && object.preview && object.preview.nodeName + ? ` ${object.preview.nodeName}` + : ""; + + const config = getElementConfig({ object, shouldRenderTooltip, name }); + + return span(config, `<!DOCTYPE${name}>`); + } + + function getElementConfig(opts) { + const { object, shouldRenderTooltip, name } = opts; + + return { + "data-link-actor-id": object.actor, + className: "objectBox objectBox-document", + title: shouldRenderTooltip ? `<!DOCTYPE${name}>` : null, + }; + } + + // Registration + function supportsObject(object, noGrip = false) { + return object?.preview && getGripType(object, noGrip) === "DocumentType"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(DocumentType), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/document.js b/devtools/client/shared/components/reps/reps/document.js new file mode 100644 index 0000000000..1ee4eeb9a7 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/document.js @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + getGripType, + getURLDisplayString, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders DOM document object. + */ + + Document.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function Document(props) { + const grip = props.object; + const shouldRenderTooltip = props.shouldRenderTooltip; + const location = getLocation(grip); + const config = getElementConfig({ grip, location, shouldRenderTooltip }); + return span( + config, + getTitle(grip), + location ? span({ className: "location" }, ` ${location}`) : null + ); + } + + function getElementConfig(opts) { + const { grip, location, shouldRenderTooltip } = opts; + const config = { + "data-link-actor-id": grip.actor, + className: "objectBox objectBox-document", + }; + + if (!shouldRenderTooltip || !location) { + return config; + } + config.title = `${grip.class} ${location}`; + return config; + } + + function getLocation(grip) { + const location = grip.preview.location; + return location ? getURLDisplayString(location) : null; + } + + function getTitle(grip) { + return span( + { + className: "objectTitle", + }, + grip.class + ); + } + + // Registration + function supportsObject(object, noGrip = false) { + return object?.preview && getGripType(object, noGrip) === "HTMLDocument"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(Document), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/element-node.js b/devtools/client/shared/components/reps/reps/element-node.js new file mode 100644 index 0000000000..cb7b0e6a32 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/element-node.js @@ -0,0 +1,310 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const { + button, + span, + } = require("devtools/client/shared/vendor/react-dom-factories"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + // Utils + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + rep: StringRep, + isLongString, + } = require("devtools/client/shared/components/reps/reps/string"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + const nodeConstants = require("devtools/client/shared/components/reps/shared/dom-node-constants"); + + const MAX_ATTRIBUTE_LENGTH = 50; + + /** + * Renders DOM element node. + */ + + ElementNode.propTypes = { + object: PropTypes.object.isRequired, + // The class should be in reps.css + inspectIconTitle: PropTypes.oneOf(["open-inspector", "highlight-node"]), + inspectIconClassName: PropTypes.string, + mode: PropTypes.oneOf(Object.values(MODE)), + onDOMNodeClick: PropTypes.func, + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + shouldRenderTooltip: PropTypes.bool, + }; + + function ElementNode(props) { + const { object, mode, shouldRenderTooltip } = props; + + const { + isAfterPseudoElement, + isBeforePseudoElement, + isMarkerPseudoElement, + } = object.preview; + + let renderElements = []; + const isInTree = object.preview && object.preview.isConnected === true; + let config = getElementConfig({ ...props, isInTree }); + const inspectIcon = getInspectIcon({ ...props, isInTree }); + + // Elements Case 1: Pseudo Element + if ( + isAfterPseudoElement || + isBeforePseudoElement || + isMarkerPseudoElement + ) { + const pseudoNodeElement = getPseudoNodeElement(object); + + // Regenerate config if shouldRenderTooltip + if (shouldRenderTooltip) { + const tooltipString = pseudoNodeElement.content; + config = getElementConfig({ ...props, tooltipString, isInTree }); + } + + // Return ONLY pseudo node element as array[0] + renderElements = [ + span(pseudoNodeElement.config, pseudoNodeElement.content), + ]; + } else if (mode === MODE.TINY) { + // Elements Case 2: MODE.TINY + const tinyElements = getTinyElements(object); + + // Regenerate config to include tooltip title + if (shouldRenderTooltip) { + // Reduce for plaintext + const tooltipString = tinyElements.reduce(function (acc, cur) { + return acc.concat(cur.content); + }, ""); + + config = getElementConfig({ ...props, tooltipString, isInTree }); + } + + // Reduce for React elements + const tinyElementsRender = tinyElements.reduce(function (acc, cur) { + acc.push(span(cur.config, cur.content)); + return acc; + }, []); + + // Render array of React spans + renderElements = tinyElementsRender; + } else { + // Elements Default case + renderElements = getElements(props); + } + + return span(config, ...renderElements, inspectIcon ? inspectIcon : null); + } + + function getElementConfig(opts) { + const { + object, + isInTree, + onDOMNodeClick, + onDOMNodeMouseOver, + onDOMNodeMouseOut, + shouldRenderTooltip, + tooltipString, + } = opts; + + // Initiate config + const config = { + "data-link-actor-id": object.actor, + "data-link-content-dom-reference": JSON.stringify( + object.contentDomReference + ), + className: "objectBox objectBox-node", + }; + + // Triage event handlers + if (isInTree) { + if (onDOMNodeClick) { + Object.assign(config, { + onClick: _ => onDOMNodeClick(object), + className: `${config.className} clickable`, + }); + } + + if (onDOMNodeMouseOver) { + Object.assign(config, { + onMouseOver: _ => onDOMNodeMouseOver(object), + }); + } + + if (onDOMNodeMouseOut) { + Object.assign(config, { + onMouseOut: _ => onDOMNodeMouseOut(object), + }); + } + } + + // If tooltip, build tooltip + if (tooltipString && shouldRenderTooltip) { + config.title = tooltipString; + } + + // Return config obj + return config; + } + + function getElements(opts) { + const { object: grip } = opts; + + const { attributes, nodeName } = grip.preview; + + const nodeNameElement = span( + { + className: "tag-name", + }, + nodeName + ); + + const attributeKeys = Object.keys(attributes); + if (attributeKeys.includes("class")) { + attributeKeys.splice(attributeKeys.indexOf("class"), 1); + attributeKeys.unshift("class"); + } + if (attributeKeys.includes("id")) { + attributeKeys.splice(attributeKeys.indexOf("id"), 1); + attributeKeys.unshift("id"); + } + const attributeElements = attributeKeys.reduce((arr, name, i, keys) => { + const value = attributes[name]; + + let title = isLongString(value) ? value.initial : value; + if (title.length < MAX_ATTRIBUTE_LENGTH) { + title = null; + } + + const attribute = span( + {}, + span({ className: "attrName" }, name), + span({ className: "attrEqual" }, "="), + StringRep({ + className: "attrValue", + object: value, + cropLimit: MAX_ATTRIBUTE_LENGTH, + title, + }) + ); + + return arr.concat([" ", attribute]); + }, []); + + return [ + span({ className: "angleBracket" }, "<"), + nodeNameElement, + ...attributeElements, + span({ className: "angleBracket" }, ">"), + ]; + } + + function getTinyElements(grip) { + const { attributes, nodeName } = grip.preview; + + // Initialize elements array + const elements = [ + { + config: { className: "tag-name" }, + content: nodeName, + }, + ]; + + // Push ID element + if (attributes.id) { + elements.push({ + config: { className: "attrName" }, + content: `#${attributes.id}`, + }); + } + + // Push Classes + if (attributes.class) { + const elementClasses = attributes.class + .trim() + .split(/\s+/) + .map(cls => `.${cls}`) + .join(""); + elements.push({ + config: { className: "attrName" }, + content: elementClasses, + }); + } + + return elements; + } + + function getPseudoNodeElement(grip) { + const { + isAfterPseudoElement, + isBeforePseudoElement, + isMarkerPseudoElement, + } = grip.preview; + + let pseudoNodeName; + + if (isAfterPseudoElement) { + pseudoNodeName = "after"; + } else if (isBeforePseudoElement) { + pseudoNodeName = "before"; + } else if (isMarkerPseudoElement) { + pseudoNodeName = "marker"; + } + + return { + config: { className: "attrName" }, + content: `::${pseudoNodeName}`, + }; + } + + function getInspectIcon(opts) { + const { + object, + isInTree, + onInspectIconClick, + inspectIconTitle, + inspectIconClassName, + onDOMNodeClick, + } = opts; + + if (!isInTree || !onInspectIconClick) { + return null; + } + + return button({ + className: inspectIconClassName || "open-inspector", + // TODO: Localize this with "openNodeInInspector" when Bug 1317038 lands + title: inspectIconTitle || "Click to select the node in the inspector", + onClick: e => { + if (onDOMNodeClick) { + e.stopPropagation(); + } + + onInspectIconClick(object, e); + }, + }); + } + + // Registration + function supportsObject(object) { + return object?.preview?.nodeType === nodeConstants.ELEMENT_NODE; + } + + // Exports from this module + module.exports = { + rep: wrapRender(ElementNode), + supportsObject, + MAX_ATTRIBUTE_LENGTH, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/error.js b/devtools/client/shared/components/reps/reps/error.js new file mode 100644 index 0000000000..9f6786adf6 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/error.js @@ -0,0 +1,330 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { + div, + span, + } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Utils + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + cleanFunctionName, + } = require("devtools/client/shared/components/reps/reps/function"); + const { + isLongString, + } = require("devtools/client/shared/components/reps/reps/string"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + const IGNORED_SOURCE_URLS = ["debugger eval code"]; + + /** + * Renders Error objects. + */ + ErrorRep.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + // An optional function that will be used to render the Error stacktrace. + renderStacktrace: PropTypes.func, + shouldRenderTooltip: PropTypes.bool, + }; + + /** + * Render an Error object. + * The customFormat prop allows to print a simplified view of the object, with only the + * message and the stacktrace, e.g.: + * Error: "blah" + * <anonymous> debugger eval code:1 + * + * The customFormat prop will only be taken into account if the mode isn't tiny and the + * depth is 0. This is because we don't want error in previews or in object to be + * displayed unlike other objects: + * - Object { err: Error } + * - â–¼ { + * err: Error: "blah" + * } + */ + function ErrorRep(props) { + const { object, mode, shouldRenderTooltip, depth } = props; + const preview = object.preview; + const customFormat = props.customFormat && mode !== MODE.TINY && !depth; + + let name; + if ( + preview && + preview.name && + typeof preview.name === "string" && + preview.kind + ) { + switch (preview.kind) { + case "Error": + name = preview.name; + break; + case "DOMException": + name = preview.kind; + break; + default: + throw new Error("Unknown preview kind for the Error rep."); + } + } else { + name = "Error"; + } + + const errorTitle = mode === MODE.TINY ? name : `${name}: `; + const content = []; + + if (customFormat) { + content.push(errorTitle); + } else { + content.push( + span({ className: "objectTitle", key: "title" }, errorTitle) + ); + } + + if (mode !== MODE.TINY) { + const { + Rep, + } = require("devtools/client/shared/components/reps/reps/rep"); + content.push( + Rep({ + ...props, + key: "message", + object: preview.message, + mode: props.mode || MODE.TINY, + useQuotes: false, + }) + ); + } + const renderStack = preview.stack && customFormat; + if (renderStack) { + const stacktrace = props.renderStacktrace + ? props.renderStacktrace(parseStackString(preview.stack)) + : getStacktraceElements(props, preview); + content.push(stacktrace); + } + + const renderCause = customFormat && preview.hasOwnProperty("cause"); + if (renderCause) { + content.push(getCauseElement(props, preview)); + } + + return span( + { + "data-link-actor-id": object.actor, + className: `objectBox-stackTrace ${ + customFormat ? "reps-custom-format" : "" + }`, + title: shouldRenderTooltip ? `${name}: "${preview.message}"` : null, + }, + ...content + ); + } + + /** + * Returns a React element reprensenting the Error stacktrace, i.e. + * transform error.stack from: + * + * semicolon@debugger eval code:1:109 + * jkl@debugger eval code:1:63 + * asdf@debugger eval code:1:28 + * @debugger eval code:1:227 + * + * Into a column layout: + * + * semicolon (<anonymous>:8:10) + * jkl (<anonymous>:5:10) + * asdf (<anonymous>:2:10) + * (<anonymous>:11:1) + */ + function getStacktraceElements(props, preview) { + const stack = []; + if (!preview.stack) { + return stack; + } + + parseStackString(preview.stack).forEach((frame, index, frames) => { + let onLocationClick; + const { filename, lineNumber, columnNumber, functionName, location } = + frame; + + if ( + props.onViewSourceInDebugger && + !IGNORED_SOURCE_URLS.includes(filename) + ) { + onLocationClick = e => { + // Don't trigger ObjectInspector expand/collapse. + e.stopPropagation(); + props.onViewSourceInDebugger({ + url: filename, + line: lineNumber, + column: columnNumber, + }); + }; + } + + stack.push( + "\t", + span( + { + key: `fn${index}`, + className: "objectBox-stackTrace-fn", + }, + cleanFunctionName(functionName) + ), + " ", + span( + { + key: `location${index}`, + className: "objectBox-stackTrace-location", + onClick: onLocationClick, + title: onLocationClick + ? `View source in debugger → ${location}` + : undefined, + }, + location + ), + "\n" + ); + }); + + return span( + { + key: "stack", + className: "objectBox-stackTrace-grid", + }, + stack + ); + } + + /** + * Returns a React element representing the cause of the Error i.e. the `cause` + * property in the second parameter of the Error constructor (`new Error("message", { cause })`) + * + * Example: + * Caused by: Error: original error + */ + function getCauseElement(props, preview) { + const { Rep } = require("devtools/client/shared/components/reps/reps/rep"); + return div( + { + key: "cause-container", + className: "error-rep-cause", + }, + "Caused by: ", + Rep({ + ...props, + key: "cause", + object: preview.cause, + mode: props.mode || MODE.TINY, + }) + ); + } + + /** + * Parse a string that should represent a stack trace and returns an array of + * the frames. The shape of the frames are extremely important as they can then + * be processed here or in the toolbox by other components. + * @param {String} stack + * @returns {Array} Array of frames, which are object with the following shape: + * - {String} filename + * - {String} functionName + * - {String} location + * - {Number} columnNumber + * - {Number} lineNumber + */ + function parseStackString(stack) { + if (!stack) { + return []; + } + + const isStacktraceALongString = isLongString(stack); + const stackString = isStacktraceALongString ? stack.initial : stack; + + if (typeof stackString !== "string") { + return []; + } + + const res = []; + stackString.split("\n").forEach((frame, index, frames) => { + if (!frame) { + // Skip any blank lines + return; + } + + // If the stacktrace is a longString, don't include the last frame in the + // array, since it is certainly incomplete. + // Can be removed when https://bugzilla.mozilla.org/show_bug.cgi?id=1448833 + // is fixed. + if (isStacktraceALongString && index === frames.length - 1) { + return; + } + + let functionName; + let location; + + // Retrieve the index of the first @ to split the frame string. + const atCharIndex = frame.indexOf("@"); + if (atCharIndex > -1) { + functionName = frame.slice(0, atCharIndex); + location = frame.slice(atCharIndex + 1); + } + + if (location && location.includes(" -> ")) { + // If the resource was loaded by base-loader.sys.mjs, the location looks like: + // resource://devtools/shared/base-loader.sys.mjs -> resource://path/to/file.js . + // What's needed is only the last part after " -> ". + location = location.split(" -> ").pop(); + } + + if (!functionName) { + functionName = "<anonymous>"; + } + + // Given the input: "scriptLocation:2:100" + // Result: + // ["scriptLocation:2:100", "scriptLocation", "2", "100"] + const locationParts = location + ? location.match(/^(.*):(\d+):(\d+)$/) + : null; + + if (location && locationParts) { + const [, filename, line, column] = locationParts; + res.push({ + filename, + functionName, + location, + columnNumber: Number(column), + lineNumber: Number(line), + }); + } + }); + + return res; + } + + // Registration + function supportsObject(object) { + return ( + object?.isError || + object?.class === "DOMException" || + object?.class === "Exception" + ); + } + + // Exports from this module + module.exports = { + rep: wrapRender(ErrorRep), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/event.js b/devtools/client/shared/components/reps/reps/event.js new file mode 100644 index 0000000000..460f7e8c31 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/event.js @@ -0,0 +1,115 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + // Reps + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + const { rep } = require("devtools/client/shared/components/reps/reps/grip"); + + /** + * Renders DOM event objects. + */ + Event.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + }; + + function Event(props) { + const gripProps = { + ...props, + title: getTitle(props), + object: { + ...props.object, + preview: { + ...props.object.preview, + ownProperties: {}, + }, + }, + }; + + if (gripProps.object.preview.target) { + Object.assign(gripProps.object.preview.ownProperties, { + target: gripProps.object.preview.target, + }); + } + Object.assign( + gripProps.object.preview.ownProperties, + gripProps.object.preview.properties + ); + + delete gripProps.object.preview.properties; + gripProps.object.ownPropertyLength = Object.keys( + gripProps.object.preview.ownProperties + ).length; + + switch (gripProps.object.class) { + case "MouseEvent": + gripProps.isInterestingProp = (type, value, name) => { + return ["target", "clientX", "clientY", "layerX", "layerY"].includes( + name + ); + }; + break; + case "KeyboardEvent": + gripProps.isInterestingProp = (type, value, name) => { + return ["target", "key", "charCode", "keyCode"].includes(name); + }; + break; + case "MessageEvent": + gripProps.isInterestingProp = (type, value, name) => { + return ["target", "isTrusted", "data"].includes(name); + }; + break; + default: + gripProps.isInterestingProp = (type, value, name) => { + // We want to show the properties in the order they are declared. + return Object.keys(gripProps.object.preview.ownProperties).includes( + name + ); + }; + } + + return rep(gripProps); + } + + function getTitle(props) { + const preview = props.object.preview; + let title = preview.type; + + if ( + preview.eventKind == "key" && + preview.modifiers && + preview.modifiers.length + ) { + title = `${title} ${preview.modifiers.join("-")}`; + } + return title; + } + + // Registration + function supportsObject(grip) { + return grip?.preview?.kind == "DOMEvent"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(Event), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/function.js b/devtools/client/shared/components/reps/reps/function.js new file mode 100644 index 0000000000..54d8905c20 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/function.js @@ -0,0 +1,264 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { + button, + span, + } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + getGripType, + cropString, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + const IGNORED_SOURCE_URLS = ["debugger eval code"]; + + /** + * This component represents a template for Function objects. + */ + + FunctionRep.propTypes = { + object: PropTypes.object.isRequired, + onViewSourceInDebugger: PropTypes.func, + shouldRenderTooltip: PropTypes.bool, + }; + + function FunctionRep(props) { + const { + object: grip, + onViewSourceInDebugger, + recordTelemetryEvent, + shouldRenderTooltip, + } = props; + + let jumpToDefinitionButton; + + // Test to see if we should display the link back to the original function definition + if ( + onViewSourceInDebugger && + grip.location && + grip.location.url && + !IGNORED_SOURCE_URLS.includes(grip.location.url) + ) { + jumpToDefinitionButton = button({ + className: "jump-definition", + draggable: false, + title: "Jump to definition", + onClick: async e => { + // Stop the event propagation so we don't trigger ObjectInspector + // expand/collapse. + e.stopPropagation(); + if (recordTelemetryEvent) { + recordTelemetryEvent("jump_to_definition"); + } + + onViewSourceInDebugger(grip.location); + }, + }); + } + + const elProps = { + "data-link-actor-id": grip.actor, + className: "objectBox objectBox-function", + // Set dir="ltr" to prevent parentheses from + // appearing in the wrong direction + dir: "ltr", + }; + + const parameterNames = (grip.parameterNames || []).filter(Boolean); + const fnTitle = getFunctionTitle(grip, props); + const fnName = getFunctionName(grip, props); + + if (grip.isClassConstructor) { + const classTitle = getClassTitle(grip, props); + const classBodyTooltip = getClassBody(parameterNames, true, props); + const classTooltip = `${classTitle ? classTitle.props.children : ""}${ + fnName ? fnName : "" + }${classBodyTooltip.join("")}`; + + elProps.title = shouldRenderTooltip ? classTooltip : null; + + return span( + elProps, + classTitle, + fnName, + ...getClassBody(parameterNames, false, props), + jumpToDefinitionButton + ); + } + + const fnTooltip = `${fnTitle ? fnTitle.props.children : ""}${ + fnName ? fnName : "" + }(${parameterNames.join(", ")})`; + + elProps.title = shouldRenderTooltip ? fnTooltip : null; + + const returnSpan = span( + elProps, + fnTitle, + fnName, + "(", + ...getParams(parameterNames), + ")", + jumpToDefinitionButton + ); + + return returnSpan; + } + + function getClassTitle(grip) { + return span( + { + className: "objectTitle", + }, + "class " + ); + } + + function getFunctionTitle(grip, props) { + const { mode } = props; + + if (mode === MODE.TINY && !grip.isGenerator && !grip.isAsync) { + return null; + } + + let title = mode === MODE.TINY ? "" : "function "; + + if (grip.isGenerator) { + title = mode === MODE.TINY ? "* " : "function* "; + } + + if (grip.isAsync) { + title = `${"async" + " "}${title}`; + } + + return span( + { + className: "objectTitle", + }, + title + ); + } + + /** + * Returns a ReactElement representing the function name. + * + * @param {Object} grip : Function grip + * @param {Object} props: Function rep props + */ + function getFunctionName(grip, props = {}) { + let { functionName } = props; + let name; + + if (functionName) { + const end = functionName.length - 1; + functionName = + functionName.startsWith('"') && functionName.endsWith('"') + ? functionName.substring(1, end) + : functionName; + } + + if ( + grip.displayName != undefined && + functionName != undefined && + grip.displayName != functionName + ) { + name = `${functionName}:${grip.displayName}`; + } else { + name = cleanFunctionName( + grip.userDisplayName || + grip.displayName || + grip.name || + props.functionName || + "" + ); + } + + return cropString(name, 100); + } + + const objectProperty = /([\w\d\$]+)$/; + const arrayProperty = /\[(.*?)\]$/; + const functionProperty = /([\w\d]+)[\/\.<]*?$/; + const annonymousProperty = /([\w\d]+)\(\^\)$/; + + /** + * Decodes an anonymous naming scheme that + * spider monkey implements based on "Naming Anonymous JavaScript Functions" + * http://johnjbarton.github.io/nonymous/index.html + * + * @param {String} name : Function name to clean up + * @returns String + */ + function cleanFunctionName(name) { + for (const reg of [ + objectProperty, + arrayProperty, + functionProperty, + annonymousProperty, + ]) { + const match = reg.exec(name); + if (match) { + return match[1]; + } + } + + return name; + } + + function getClassBody(constructorParams, textOnly = false, props) { + const { mode } = props; + + if (mode === MODE.TINY) { + return []; + } + + return [" {", ...getClassConstructor(textOnly, constructorParams), "}"]; + } + + function getClassConstructor(textOnly = false, parameterNames) { + if (parameterNames.length === 0) { + return []; + } + + if (textOnly) { + return [` constructor(${parameterNames.join(", ")}) `]; + } + return [" constructor(", ...getParams(parameterNames), ") "]; + } + + function getParams(parameterNames) { + return parameterNames.flatMap((param, index, arr) => { + return [ + span({ className: "param" }, param), + index === arr.length - 1 ? "" : span({ className: "delimiter" }, ", "), + ]; + }); + } + + // Registration + function supportsObject(grip, noGrip = false) { + return getGripType(grip, noGrip) === "Function"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(FunctionRep), + supportsObject, + cleanFunctionName, + // exported for testing purpose. + getFunctionName, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/grip-array.js b/devtools/client/shared/components/reps/reps/grip-array.js new file mode 100644 index 0000000000..23589fc90f --- /dev/null +++ b/devtools/client/shared/components/reps/reps/grip-array.js @@ -0,0 +1,255 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const { + lengthBubble, + } = require("devtools/client/shared/components/reps/shared/grip-length-bubble"); + const { + interleave, + getGripType, + wrapRender, + ellipsisElement, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + const DEFAULT_TITLE = "Array"; + + /** + * Renders an array. The array is enclosed by left and right bracket + * and the max number of rendered items depends on the current mode. + */ + + GripArray.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + provider: PropTypes.object, + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + shouldRenderTooltip: PropTypes.bool, + }; + + function GripArray(props) { + const { object, mode = MODE.SHORT, shouldRenderTooltip } = props; + + let brackets; + const needSpace = function (space) { + return space ? { left: "[ ", right: " ]" } : { left: "[", right: "]" }; + }; + + const config = { + "data-link-actor-id": object.actor, + className: "objectBox objectBox-array", + title: shouldRenderTooltip ? "Array" : null, + }; + + const title = getTitle(props, object); + + if (mode === MODE.TINY) { + const isEmpty = getLength(object) === 0; + + // Omit bracketed ellipsis for non-empty non-Array arraylikes (f.e: Sets). + if (!isEmpty && object.class !== "Array") { + return span(config, title); + } + + brackets = needSpace(false); + return span( + config, + title, + span( + { + className: "arrayLeftBracket", + }, + brackets.left + ), + isEmpty ? null : ellipsisElement, + span( + { + className: "arrayRightBracket", + }, + brackets.right + ) + ); + } + + const max = maxLengthMap.get(mode); + const items = arrayIterator(props, object, max); + brackets = needSpace(!!items.length); + + return span( + config, + title, + span( + { + className: "arrayLeftBracket", + }, + brackets.left + ), + ...interleave(items, ", "), + span( + { + className: "arrayRightBracket", + }, + brackets.right + ), + span({ + className: "arrayProperties", + role: "group", + }) + ); + } + + function getLength(grip) { + if (!grip.preview) { + return 0; + } + + return grip.preview.length || grip.preview.childNodesLength || 0; + } + + function getTitle(props, object) { + const objectLength = getLength(object); + const isEmpty = objectLength === 0; + + let title = props.title || object.class || DEFAULT_TITLE; + + const length = lengthBubble({ + object, + mode: props.mode, + maxLengthMap, + getLength, + }); + + if (props.mode === MODE.TINY) { + if (isEmpty) { + if (object.class === DEFAULT_TITLE) { + return null; + } + + return span({ className: "objectTitle" }, `${title} `); + } + + let trailingSpace; + if (object.class === DEFAULT_TITLE) { + title = null; + trailingSpace = " "; + } + + return span({ className: "objectTitle" }, title, length, trailingSpace); + } + + return span({ className: "objectTitle" }, title, length, " "); + } + + function getPreviewItems(grip) { + if (!grip.preview) { + return null; + } + + return grip.preview.items || grip.preview.childNodes || []; + } + + function arrayIterator(props, grip, max) { + const { Rep } = require("devtools/client/shared/components/reps/reps/rep"); + + let items = []; + const gripLength = getLength(grip); + + if (!gripLength) { + return items; + } + + const previewItems = getPreviewItems(grip); + const provider = props.provider; + + let emptySlots = 0; + let foldedEmptySlots = 0; + items = previewItems.reduce((res, itemGrip) => { + if (res.length >= max) { + return res; + } + + let object; + try { + if (!provider && itemGrip === null) { + emptySlots++; + return res; + } + + object = provider ? provider.getValue(itemGrip) : itemGrip; + } catch (exc) { + object = exc; + } + + if (emptySlots > 0) { + res.push(getEmptySlotsElement(emptySlots)); + foldedEmptySlots = foldedEmptySlots + emptySlots - 1; + emptySlots = 0; + } + + if (res.length < max) { + res.push( + Rep({ + ...props, + object, + mode: MODE.TINY, + // Do not propagate title to array items reps + title: undefined, + }) + ); + } + + return res; + }, []); + + // Handle trailing empty slots if there are some. + if (items.length < max && emptySlots > 0) { + items.push(getEmptySlotsElement(emptySlots)); + foldedEmptySlots = foldedEmptySlots + emptySlots - 1; + } + + const itemsShown = items.length + foldedEmptySlots; + if (gripLength > itemsShown) { + items.push(ellipsisElement); + } + + return items; + } + + function getEmptySlotsElement(number) { + // TODO: Use l10N - See https://github.com/firefox-devtools/reps/issues/141 + return `<${number} empty slot${number > 1 ? "s" : ""}>`; + } + + function supportsObject(grip, noGrip = false) { + return ( + grip?.preview && + (grip.preview.kind == "ArrayLike" || + getGripType(grip, noGrip) === "DocumentFragment") + ); + } + + const maxLengthMap = new Map(); + maxLengthMap.set(MODE.SHORT, 3); + maxLengthMap.set(MODE.LONG, 10); + + // Exports from this module + module.exports = { + rep: wrapRender(GripArray), + supportsObject, + maxLengthMap, + getLength, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/grip-entry.js b/devtools/client/shared/components/reps/reps/grip-entry.js new file mode 100644 index 0000000000..8bc5585c09 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/grip-entry.js @@ -0,0 +1,77 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + // Utils + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const PropRep = require("devtools/client/shared/components/reps/reps/prop-rep"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + /** + * Renders an entry of a Map, (Local|Session)Storage, Header or FormData entry. + */ + GripEntry.propTypes = { + object: PropTypes.object, + mode: PropTypes.oneOf(Object.values(MODE)), + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + }; + + function GripEntry(props) { + const { object } = props; + + let { key, value } = object.preview; + if (key && key.getGrip) { + key = key.getGrip(); + } + if (value && value.getGrip) { + value = value.getGrip(); + } + + return span( + { + className: "objectBox objectBox-map-entry", + }, + PropRep({ + ...props, + name: key, + object: value, + equal: " \u2192 ", + title: null, + suppressQuotes: false, + }) + ); + } + + function supportsObject(grip, noGrip = false) { + if (noGrip === true) { + return false; + } + return ( + grip && + (grip.type === "mapEntry" || + grip.type === "storageEntry" || + grip.type === "formDataEntry" || + grip.type === "urlSearchParamsEntry") && + grip.preview + ); + } + + // Exports from this module + module.exports = { + rep: wrapRender(GripEntry), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/grip-map.js b/devtools/client/shared/components/reps/reps/grip-map.js new file mode 100644 index 0000000000..d0157137ef --- /dev/null +++ b/devtools/client/shared/components/reps/reps/grip-map.js @@ -0,0 +1,234 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const { + lengthBubble, + } = require("devtools/client/shared/components/reps/shared/grip-length-bubble"); + const { + interleave, + wrapRender, + ellipsisElement, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const PropRep = require("devtools/client/shared/components/reps/reps/prop-rep"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + /** + * Renders an map. A map is represented by a list of its + * entries enclosed in curly brackets. + */ + + GripMap.propTypes = { + object: PropTypes.object, + mode: PropTypes.oneOf(Object.values(MODE)), + isInterestingEntry: PropTypes.func, + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + title: PropTypes.string, + shouldRenderTooltip: PropTypes.bool, + }; + + function GripMap(props) { + const { mode, object, shouldRenderTooltip } = props; + + const config = { + "data-link-actor-id": object.actor, + className: "objectBox objectBox-object", + title: shouldRenderTooltip ? getTooltip(object, props) : null, + }; + + const title = getTitle(props, object); + const isEmpty = getLength(object) === 0; + + if (isEmpty || mode === MODE.TINY) { + return span(config, title); + } + + const propsArray = safeEntriesIterator( + props, + object, + maxLengthMap.get(mode) + ); + + return span( + config, + title, + span( + { + className: "objectLeftBrace", + }, + " { " + ), + ...interleave(propsArray, ", "), + span( + { + className: "objectRightBrace", + }, + " }" + ) + ); + } + + function getTitle(props, object) { + const title = + props.title || (object && object.class ? object.class : "Map"); + return span( + { + className: "objectTitle", + }, + title, + lengthBubble({ + object, + mode: props.mode, + maxLengthMap, + getLength, + showZeroLength: true, + }) + ); + } + + function getTooltip(object, props) { + const tooltip = + props.title || (object && object.class ? object.class : "Map"); + return `${tooltip}(${getLength(object)})`; + } + + function safeEntriesIterator(props, object, max) { + max = typeof max === "undefined" ? 3 : max; + try { + return entriesIterator(props, object, max); + } catch (err) { + console.error(err); + } + return []; + } + + function entriesIterator(props, object, max) { + // Entry filter. Show only interesting entries to the user. + const isInterestingEntry = + props.isInterestingEntry || + ((type, value) => { + return ( + type == "boolean" || + type == "number" || + (type == "string" && !!value.length) + ); + }); + + const mapEntries = + object.preview && object.preview.entries ? object.preview.entries : []; + + let indexes = getEntriesIndexes(mapEntries, max, isInterestingEntry); + if (indexes.length < max && indexes.length < mapEntries.length) { + // There are not enough entries yet, so we add uninteresting entries. + indexes = indexes.concat( + getEntriesIndexes( + mapEntries, + max - indexes.length, + (t, value, name) => { + return !isInterestingEntry(t, value, name); + } + ) + ); + } + + const entries = getEntries(props, mapEntries, indexes); + if (entries.length < getLength(object)) { + // There are some undisplayed entries. Then display "…". + entries.push(ellipsisElement); + } + + return entries; + } + + /** + * Get entries ordered by index. + * + * @param {Object} props Component props. + * @param {Array} entries Entries array. + * @param {Array} indexes Indexes of entries. + * @return {Array} Array of PropRep. + */ + function getEntries(props, entries, indexes) { + const { onDOMNodeMouseOver, onDOMNodeMouseOut, onInspectIconClick } = props; + + // Make indexes ordered by ascending. + indexes.sort(function (a, b) { + return a - b; + }); + + return indexes.map((index, i) => { + const [key, entryValue] = entries[index]; + const value = + entryValue.value !== undefined ? entryValue.value : entryValue; + + return PropRep({ + name: key && key.getGrip ? key.getGrip() : key, + equal: " \u2192 ", + object: value && value.getGrip ? value.getGrip() : value, + mode: MODE.TINY, + onDOMNodeMouseOver, + onDOMNodeMouseOut, + onInspectIconClick, + }); + }); + } + + /** + * Get the indexes of entries in the map. + * + * @param {Array} entries Entries array. + * @param {Number} max The maximum length of indexes array. + * @param {Function} filter Filter the entry you want. + * @return {Array} Indexes of filtered entries in the map. + */ + function getEntriesIndexes(entries, max, filter) { + return entries.reduce((indexes, [key, entry], i) => { + if (indexes.length < max) { + const value = entry && entry.value !== undefined ? entry.value : entry; + // Type is specified in grip's "class" field and for primitive + // values use typeof. + const type = ( + value && value.class ? value.class : typeof value + ).toLowerCase(); + + if (filter(type, value, key)) { + indexes.push(i); + } + } + + return indexes; + }, []); + } + + function getLength(grip) { + return grip.preview.size || 0; + } + + function supportsObject(grip) { + return grip?.preview?.kind == "MapLike"; + } + + const maxLengthMap = new Map(); + maxLengthMap.set(MODE.SHORT, 3); + maxLengthMap.set(MODE.LONG, 10); + + // Exports from this module + module.exports = { + rep: wrapRender(GripMap), + supportsObject, + maxLengthMap, + getLength, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/grip.js b/devtools/client/shared/components/reps/reps/grip.js new file mode 100644 index 0000000000..604496d376 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/grip.js @@ -0,0 +1,396 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Dependencies + const { + interleave, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const PropRep = require("devtools/client/shared/components/reps/reps/prop-rep"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + /** + * Renders generic grip. Grip is client representation + * of remote JS object and is used as an input object + * for this rep component. + */ + + GripRep.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + isInterestingProp: PropTypes.func, + title: PropTypes.string, + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + noGrip: PropTypes.bool, + shouldRenderTooltip: PropTypes.bool, + }; + + const DEFAULT_TITLE = "Object"; + + function GripRep(props) { + const { mode = MODE.SHORT, object, shouldRenderTooltip } = props; + + const config = { + "data-link-actor-id": object.actor, + className: "objectBox objectBox-object", + }; + + if (mode === MODE.TINY) { + const propertiesLength = getPropertiesLength(object); + + const tinyModeItems = []; + if (getTitle(props, object) !== DEFAULT_TITLE) { + tinyModeItems.push(getTitleElement(props, object)); + } else { + tinyModeItems.push( + span( + { + className: "objectLeftBrace", + }, + "{" + ), + propertiesLength > 0 + ? span( + { + key: "more", + className: "more-ellipsis", + }, + "…" + ) + : null, + span( + { + className: "objectRightBrace", + }, + "}" + ) + ); + } + + config.title = shouldRenderTooltip ? getTitle(props, object) : null; + + return span(config, ...tinyModeItems); + } + + const propsArray = safePropIterator(props, object, maxLengthMap.get(mode)); + + config.title = shouldRenderTooltip ? getTitle(props, object) : null; + + return span( + config, + getTitleElement(props, object), + span( + { + className: "objectLeftBrace", + }, + " { " + ), + ...interleave(propsArray, ", "), + span( + { + className: "objectRightBrace", + }, + " }" + ) + ); + } + + function getTitleElement(props, object) { + return span( + { + className: "objectTitle", + }, + getTitle(props, object) + ); + } + + function getTitle(props, object) { + return props.title || object.class || DEFAULT_TITLE; + } + + function getPropertiesLength(object) { + let propertiesLength = + object.preview && object.preview.ownPropertiesLength + ? object.preview.ownPropertiesLength + : object.ownPropertyLength; + + if (object.preview && object.preview.safeGetterValues) { + propertiesLength += Object.keys(object.preview.safeGetterValues).length; + } + + if (object.preview && object.preview.ownSymbols) { + propertiesLength += object.preview.ownSymbolsLength; + } + + if (object.preview && object.preview.privateProperties) { + propertiesLength += object.preview.privatePropertiesLength; + } + + return propertiesLength; + } + + function safePropIterator(props, object, max) { + max = typeof max === "undefined" ? maxLengthMap.get(MODE.SHORT) : max; + try { + return propIterator(props, object, max); + } catch (err) { + console.error(err); + } + return []; + } + + function propIterator(props, object, max) { + if ( + object.preview && + Object.keys(object.preview).includes("wrappedValue") + ) { + const { + Rep, + } = require("devtools/client/shared/components/reps/reps/rep"); + + return [ + Rep({ + object: object.preview.wrappedValue, + mode: props.mode || MODE.TINY, + defaultRep: Grip, + }), + ]; + } + + // Property filter. Show only interesting properties to the user. + const isInterestingProp = + props.isInterestingProp || + ((type, value) => { + return ( + type == "boolean" || + type == "number" || + (type == "string" && !!value.length) + ); + }); + + let properties = object.preview ? object.preview.ownProperties || {} : {}; + + const propertiesLength = getPropertiesLength(object); + + if (object.preview && object.preview.safeGetterValues) { + properties = { ...properties, ...object.preview.safeGetterValues }; + } + + let indexes = getPropIndexes(properties, max, isInterestingProp); + if (indexes.length < max && indexes.length < propertiesLength) { + // There are not enough props yet. + // Then add uninteresting props to display them. + indexes = indexes.concat( + getPropIndexes(properties, max - indexes.length, (t, value, name) => { + return !isInterestingProp(t, value, name); + }) + ); + } + + // The server synthesizes some property names for a Proxy, like + // <target> and <handler>; we don't want to quote these because, + // as synthetic properties, they appear more natural when + // unquoted. Analogous for a Promise. + const suppressQuotes = ["Proxy", "Promise"].includes(object.class); + const propsArray = getProps(props, properties, indexes, suppressQuotes); + + // Show private properties + if (object.preview && object.preview.privateProperties) { + const { privateProperties } = object.preview; + const length = max - indexes.length; + + const privateProps = privateProperties.slice(0, length).map(item => { + const value = item.descriptor.value; + const grip = value && value.getGrip ? value.getGrip() : value; + + return PropRep({ + ...props, + keyClassName: "private", + mode: MODE.TINY, + name: item.name, + object: grip, + equal: ": ", + defaultRep: Grip, + title: null, + suppressQuotes: true, + }); + }); + + propsArray.push(...privateProps); + } + + // Show symbols. + if (object.preview && object.preview.ownSymbols) { + const { ownSymbols } = object.preview; + const length = max - indexes.length; + + const symbolsProps = ownSymbols.slice(0, length).map(symbolItem => { + const symbolValue = symbolItem.descriptor.value; + const symbolGrip = + symbolValue && symbolValue.getGrip + ? symbolValue.getGrip() + : symbolValue; + + return PropRep({ + ...props, + mode: MODE.TINY, + name: symbolItem, + object: symbolGrip, + equal: ": ", + defaultRep: Grip, + title: null, + suppressQuotes, + }); + }); + + propsArray.push(...symbolsProps); + } + + if ( + Object.keys(properties).length > max || + propertiesLength > max || + // When the object has non-enumerable properties, we don't have them in the + // packet, but we might want to show there's something in the object. + propertiesLength > propsArray.length + ) { + // There are some undisplayed props. Then display "more...". + propsArray.push( + span( + { + key: "more", + className: "more-ellipsis", + }, + "…" + ) + ); + } + + return propsArray; + } + + /** + * Get props ordered by index. + * + * @param {Object} componentProps Grip Component props. + * @param {Object} properties Properties of the object the Grip describes. + * @param {Array} indexes Indexes of properties. + * @param {Boolean} suppressQuotes true if we should suppress quotes + * on property names. + * @return {Array} Props. + */ + function getProps(componentProps, properties, indexes, suppressQuotes) { + // Make indexes ordered by ascending. + indexes.sort(function (a, b) { + return a - b; + }); + + const propertiesKeys = Object.keys(properties); + return indexes.map(i => { + const name = propertiesKeys[i]; + const value = getPropValue(properties[name]); + + return PropRep({ + ...componentProps, + mode: MODE.TINY, + name, + object: value, + equal: ": ", + defaultRep: Grip, + title: null, + suppressQuotes, + }); + }); + } + + /** + * Get the indexes of props in the object. + * + * @param {Object} properties Props object. + * @param {Number} max The maximum length of indexes array. + * @param {Function} filter Filter the props you want. + * @return {Array} Indexes of interesting props in the object. + */ + function getPropIndexes(properties, max, filter) { + const indexes = []; + + try { + let i = 0; + for (const name in properties) { + if (indexes.length >= max) { + return indexes; + } + + // Type is specified in grip's "class" field and for primitive + // values use typeof. + const value = getPropValue(properties[name]); + let type = value.class || typeof value; + type = type.toLowerCase(); + + if (filter(type, value, name)) { + indexes.push(i); + } + i++; + } + } catch (err) { + console.error(err); + } + return indexes; + } + + /** + * Get the actual value of a property. + * + * @param {Object} property + * @return {Object} Value of the property. + */ + function getPropValue(property) { + let value = property; + if (typeof property === "object") { + const keys = Object.keys(property); + if (keys.includes("value")) { + value = property.value; + } else if (keys.includes("getterValue")) { + value = property.getterValue; + } + } + return value; + } + + // Registration + function supportsObject(object, noGrip = false) { + if (object?.class === "DeadObject") { + return true; + } + + return object?.preview + ? typeof object.preview.ownProperties !== "undefined" + : typeof object?.ownPropertyLength !== "undefined"; + } + + const maxLengthMap = new Map(); + maxLengthMap.set(MODE.SHORT, 3); + maxLengthMap.set(MODE.LONG, 10); + + // Grip is used in propIterator and has to be defined here. + const Grip = { + rep: wrapRender(GripRep), + supportsObject, + maxLengthMap, + }; + + // Exports from this module + module.exports = Grip; +}); diff --git a/devtools/client/shared/components/reps/reps/infinity.js b/devtools/client/shared/components/reps/reps/infinity.js new file mode 100644 index 0000000000..2a36698611 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/infinity.js @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders a Infinity object + */ + + InfinityRep.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function InfinityRep(props) { + const { object, shouldRenderTooltip } = props; + + const config = getElementConfig(shouldRenderTooltip, object); + + return span(config, object.type); + } + + function getElementConfig(shouldRenderTooltip, object) { + return { + className: "objectBox objectBox-number", + title: shouldRenderTooltip ? object.type : null, + }; + } + + function supportsObject(object, noGrip = false) { + const type = getGripType(object, noGrip); + return type == "Infinity" || type == "-Infinity"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(InfinityRep), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/moz.build b/devtools/client/shared/components/reps/reps/moz.build new file mode 100644 index 0000000000..30b7e72a73 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/moz.build @@ -0,0 +1,45 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "accessible.js", + "accessor.js", + "array.js", + "attribute.js", + "big-int.js", + "comment-node.js", + "constants.js", + "custom-formatter.js", + "date-time.js", + "document-type.js", + "document.js", + "element-node.js", + "error.js", + "event.js", + "function.js", + "grip-array.js", + "grip-entry.js", + "grip-map.js", + "grip.js", + "infinity.js", + "nan.js", + "null.js", + "number.js", + "object-with-text.js", + "object-with-url.js", + "object.js", + "promise.js", + "prop-rep.js", + "regexp.js", + "rep-utils.js", + "rep.js", + "string.js", + "stylesheet.js", + "symbol.js", + "text-node.js", + "undefined.js", + "window.js", +) diff --git a/devtools/client/shared/components/reps/reps/nan.js b/devtools/client/shared/components/reps/reps/nan.js new file mode 100644 index 0000000000..d8b022177c --- /dev/null +++ b/devtools/client/shared/components/reps/reps/nan.js @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders a NaN object + */ + + NaNRep.PropTypes = { + shouldRenderTooltip: PropTypes.bool, + }; + + function NaNRep(props) { + const shouldRenderTooltip = props.shouldRenderTooltip; + + const config = getElementConfig(shouldRenderTooltip); + + return span(config, "NaN"); + } + + function getElementConfig(shouldRenderTooltip) { + return { + className: "objectBox objectBox-nan", + title: shouldRenderTooltip ? "NaN" : null, + }; + } + + function supportsObject(object, noGrip = false) { + return getGripType(object, noGrip) == "NaN"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(NaNRep), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/null.js b/devtools/client/shared/components/reps/reps/null.js new file mode 100644 index 0000000000..53dca6b8f3 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/null.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders null value + */ + + Null.PropTypes = { + shouldRenderTooltip: PropTypes.bool, + }; + + function Null(props) { + const shouldRenderTooltip = props.shouldRenderTooltip; + + const config = getElementConfig(shouldRenderTooltip); + + return span(config, "null"); + } + + function getElementConfig(shouldRenderTooltip) { + return { + className: "objectBox objectBox-null", + title: shouldRenderTooltip ? "null" : null, + }; + } + + function supportsObject(object, noGrip = false) { + if (noGrip === true) { + return object === null; + } + + if (object && object.type && object.type == "null") { + return true; + } + + return object == null; + } + + // Exports from this module + + module.exports = { + rep: wrapRender(Null), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/number.js b/devtools/client/shared/components/reps/reps/number.js new file mode 100644 index 0000000000..455cb971e7 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/number.js @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders a number + */ + + Number.propTypes = { + object: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.number, + PropTypes.bool, + ]).isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function Number(props) { + const value = stringify(props.object); + const config = getElementConfig(props.shouldRenderTooltip, value); + + return span(config, value); + } + + function stringify(object) { + const isNegativeZero = + Object.is(object, -0) || (object.type && object.type == "-0"); + + return isNegativeZero ? "-0" : String(object); + } + + function getElementConfig(shouldRenderTooltip, value) { + return { + className: "objectBox objectBox-number", + title: shouldRenderTooltip ? value : null, + }; + } + + const SUPPORTED_TYPES = new Set(["boolean", "number", "-0"]); + function supportsObject(object, noGrip = false) { + return SUPPORTED_TYPES.has(getGripType(object, noGrip)); + } + + // Exports from this module + + module.exports = { + rep: wrapRender(Number), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/object-with-text.js b/devtools/client/shared/components/reps/reps/object-with-text.js new file mode 100644 index 0000000000..5dc3f27ae8 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/object-with-text.js @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + const String = + require("devtools/client/shared/components/reps/reps/string").rep; + + /** + * Renders a grip object with textual data. + */ + + ObjectWithText.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function ObjectWithText(props) { + const grip = props.object; + const config = getElementConfig(props); + + return span(config, `${getType(grip)} `, getDescription(grip)); + } + + function getElementConfig(opts) { + const shouldRenderTooltip = opts.shouldRenderTooltip; + const grip = opts.object; + + return { + "data-link-actor-id": grip.actor, + className: `objectTitle objectBox objectBox-${getType(grip)}`, + title: shouldRenderTooltip + ? `${getType(grip)} "${grip.preview.text}"` + : null, + }; + } + + function getType(grip) { + return grip.class; + } + + function getDescription(grip) { + return String({ + object: grip.preview.text, + }); + } + + // Registration + function supportsObject(grip) { + return grip?.preview?.kind == "ObjectWithText"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(ObjectWithText), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/object-with-url.js b/devtools/client/shared/components/reps/reps/object-with-url.js new file mode 100644 index 0000000000..0d0660cacf --- /dev/null +++ b/devtools/client/shared/components/reps/reps/object-with-url.js @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + getURLDisplayString, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders a grip object with URL data. + */ + + ObjectWithURL.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function ObjectWithURL(props) { + const grip = props.object; + const config = getElementConfig(props); + + return span( + config, + getTitle(grip), + span({ className: "objectPropValue" }, getDescription(grip)) + ); + } + + function getElementConfig(opts) { + const grip = opts.object; + const shouldRenderTooltip = opts.shouldRenderTooltip; + const tooltip = `${getType(grip)} ${getDescription(grip)}`; + + return { + "data-link-actor-id": grip.actor, + className: `objectBox objectBox-${getType(grip)}`, + title: shouldRenderTooltip ? tooltip : null, + }; + } + + function getTitle(grip) { + return span({ className: "objectTitle" }, `${getType(grip)} `); + } + + function getType(grip) { + return grip.class; + } + + function getDescription(grip) { + return getURLDisplayString(grip.preview.url); + } + + // Registration + function supportsObject(grip) { + return grip?.preview?.kind == "ObjectWithURL"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(ObjectWithURL), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/object.js b/devtools/client/shared/components/reps/reps/object.js new file mode 100644 index 0000000000..bdd98cfd04 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/object.js @@ -0,0 +1,207 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const { + wrapRender, + ellipsisElement, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const PropRep = require("devtools/client/shared/components/reps/reps/prop-rep"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + const DEFAULT_TITLE = "Object"; + + /** + * Renders an object. An object is represented by a list of its + * properties enclosed in curly brackets. + */ + + ObjectRep.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + title: PropTypes.string, + shouldRenderTooltip: PropTypes.bool, + }; + + function ObjectRep(props) { + const object = props.object; + const { shouldRenderTooltip = true } = props; + + if (props.mode === MODE.TINY) { + const tinyModeItems = []; + if (getTitle(props) !== DEFAULT_TITLE) { + tinyModeItems.push(getTitleElement(props)); + } else { + tinyModeItems.push( + span( + { + className: "objectLeftBrace", + }, + "{" + ), + Object.keys(object).length ? ellipsisElement : null, + span( + { + className: "objectRightBrace", + }, + "}" + ) + ); + } + + return span( + { + className: "objectBox objectBox-object", + title: shouldRenderTooltip ? getTitle(props) : null, + }, + ...tinyModeItems + ); + } + + const propsArray = safePropIterator(props, object); + + return span( + { + className: "objectBox objectBox-object", + title: shouldRenderTooltip ? getTitle(props) : null, + }, + getTitleElement(props), + span( + { + className: "objectLeftBrace", + }, + " { " + ), + ...propsArray, + span( + { + className: "objectRightBrace", + }, + " }" + ) + ); + } + + function getTitleElement(props) { + return span({ className: "objectTitle" }, getTitle(props)); + } + + function getTitle(props) { + return props.title || DEFAULT_TITLE; + } + + function safePropIterator(props, object, max) { + max = typeof max === "undefined" ? 3 : max; + try { + return propIterator(props, object, max); + } catch (err) { + console.error(err); + } + return []; + } + + function propIterator(props, object, max) { + // Work around https://bugzilla.mozilla.org/show_bug.cgi?id=945377 + if (Object.prototype.toString.call(object) === "[object Generator]") { + object = Object.getPrototypeOf(object); + } + + const elements = []; + const unimportantProperties = []; + let propertiesNumber = 0; + const propertiesNames = Object.keys(object); + + const pushPropRep = (name, value) => { + elements.push( + PropRep({ + ...props, + key: name, + mode: MODE.TINY, + name, + object: value, + equal: ": ", + }) + ); + propertiesNumber++; + + if (propertiesNumber < propertiesNames.length) { + elements.push(", "); + } + }; + + try { + for (const name of propertiesNames) { + if (propertiesNumber >= max) { + break; + } + + let value; + try { + value = object[name]; + } catch (exc) { + continue; + } + + // Object members with non-empty values are preferred since it gives the + // user a better overview of the object. + if (isInterestingProp(value)) { + pushPropRep(name, value); + } else { + // If the property is not important, put its name on an array for later + // use. + unimportantProperties.push(name); + } + } + } catch (err) { + console.error(err); + } + + if (propertiesNumber < max) { + for (const name of unimportantProperties) { + if (propertiesNumber >= max) { + break; + } + + let value; + try { + value = object[name]; + } catch (exc) { + continue; + } + + pushPropRep(name, value); + } + } + + if (propertiesNumber < propertiesNames.length) { + elements.push(ellipsisElement); + } + + return elements; + } + + function isInterestingProp(value) { + const type = typeof value; + return type == "boolean" || type == "number" || (type == "string" && value); + } + + function supportsObject(object, noGrip = false) { + return noGrip; + } + + // Exports from this module + module.exports = { + rep: wrapRender(ObjectRep), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/promise.js b/devtools/client/shared/components/reps/reps/promise.js new file mode 100644 index 0000000000..db21f29477 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/promise.js @@ -0,0 +1,101 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Dependencies + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + const Grip = require("devtools/client/shared/components/reps/reps/grip"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + /** + * Renders a DOM Promise object. + */ + + PromiseRep.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + shouldRenderTooltip: PropTypes.bool, + }; + + function PromiseRep(props) { + const object = props.object; + + // @backward-compat { version 85 } On older servers, the preview of a promise was + // useless and didn't include the internal promise state, which was directly exposed + // in the grip. + if (object.promiseState) { + const { state, value, reason } = object.promiseState; + const ownProperties = Object.create(null); + ownProperties["<state>"] = { value: state }; + let ownPropertiesLength = 1; + if (state == "fulfilled") { + ownProperties["<value>"] = { value }; + ++ownPropertiesLength; + } else if (state == "rejected") { + ownProperties["<reason>"] = { value: reason }; + ++ownPropertiesLength; + } + object.preview = { + kind: "Object", + ownProperties, + ownPropertiesLength, + }; + } + + if (props.mode !== MODE.TINY) { + return Grip.rep(props); + } + + const shouldRenderTooltip = props.shouldRenderTooltip; + const config = { + "data-link-actor-id": object.actor, + className: "objectBox objectBox-object", + title: shouldRenderTooltip ? "Promise" : null, + }; + + const { Rep } = require("devtools/client/shared/components/reps/reps/rep"); + + return span( + config, + getTitle(object), + span({ className: "objectLeftBrace" }, " { "), + Rep({ object: object.preview.ownProperties["<state>"].value }), + span({ className: "objectRightBrace" }, " }") + ); + } + + function getTitle(object) { + return span({ className: "objectTitle" }, object.class); + } + + // Registration + function supportsObject(object, noGrip = false) { + if (!Grip.supportsObject(object, noGrip)) { + return false; + } + return getGripType(object, noGrip) == "Promise"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(PromiseRep), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/prop-rep.js b/devtools/client/shared/components/reps/reps/prop-rep.js new file mode 100644 index 0000000000..a0ab4d084c --- /dev/null +++ b/devtools/client/shared/components/reps/reps/prop-rep.js @@ -0,0 +1,105 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const { + maybeEscapePropertyName, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + /** + * Property for Obj (local JS objects), Grip (remote JS objects) + * and GripMap (remote JS maps and weakmaps) reps. + * It's used to render object properties. + */ + PropRep.propTypes = { + // Additional class to set on the key element + keyClassName: PropTypes.string, + // Property name. + name: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, + // Equal character rendered between property name and value. + equal: PropTypes.string, + mode: PropTypes.oneOf(Object.values(MODE)), + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + // Normally a PropRep will quote a property name that isn't valid + // when unquoted; but this flag can be used to suppress the + // quoting. + suppressQuotes: PropTypes.bool, + shouldRenderTooltip: PropTypes.bool, + }; + + /** + * Function that given a name, a delimiter and an object returns an array + * of React elements representing an object property (e.g. `name: value`) + * + * @param {Object} props + * @return {Array} Array of React elements. + */ + + function PropRep(props) { + const Grip = require("devtools/client/shared/components/reps/reps/grip"); + const { Rep } = require("devtools/client/shared/components/reps/reps/rep"); + + let { + equal, + keyClassName, + mode, + name, + shouldRenderTooltip, + suppressQuotes, + } = props; + + const className = `nodeName${keyClassName ? " " + keyClassName : ""}`; + + let key; + // The key can be a simple string, for plain objects, + // or another object for maps and weakmaps. + if (typeof name === "string") { + if (!suppressQuotes) { + name = maybeEscapePropertyName(name); + } + key = span( + { + className, + title: shouldRenderTooltip ? name : null, + }, + name + ); + } else { + key = Rep({ + ...props, + className, + object: name, + mode: mode || MODE.TINY, + defaultRep: Grip, + }); + } + + return [ + key, + span( + { + className: "objectEqual", + }, + equal + ), + Rep({ ...props }), + ]; + } + + // Exports from this module + module.exports = wrapRender(PropRep); +}); diff --git a/devtools/client/shared/components/reps/reps/regexp.js b/devtools/client/shared/components/reps/reps/regexp.js new file mode 100644 index 0000000000..fc753e0fca --- /dev/null +++ b/devtools/client/shared/components/reps/reps/regexp.js @@ -0,0 +1,66 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + getGripType, + wrapRender, + ELLIPSIS, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders a grip object with regular expression. + */ + + RegExp.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function RegExp(props) { + const { object } = props; + const config = getElementConfig(props); + + return span(config, getSource(object)); + } + + function getElementConfig(opts) { + const { object, shouldRenderTooltip } = opts; + const text = getSource(object); + + return { + "data-link-actor-id": object.actor, + className: "objectBox objectBox-regexp regexpSource", + title: shouldRenderTooltip ? text : null, + }; + } + + function getSource(grip) { + const { displayString } = grip; + if (displayString?.type === "longString") { + return `${displayString.initial}${ELLIPSIS}`; + } + + return displayString; + } + + // Registration + function supportsObject(object, noGrip = false) { + return getGripType(object, noGrip) == "RegExp"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(RegExp), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/rep-utils.js b/devtools/client/shared/components/reps/reps/rep-utils.js new file mode 100644 index 0000000000..390cb0f4f1 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/rep-utils.js @@ -0,0 +1,574 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const validProtocols = /(http|https|ftp|data|resource|chrome):/i; + + // URL Regex, common idioms: + // + // Lead-in (URL): + // ( Capture because we need to know if there was a lead-in + // character so we can include it as part of the text + // preceding the match. We lack look-behind matching. + // ^| The URL can start at the beginning of the string. + // [\s(,;'"`“] Or whitespace or some punctuation that does not imply + // a context which would preclude a URL. + // ) + // + // We do not need a trailing look-ahead because our regex's will terminate + // because they run out of characters they can eat. + + // What we do not attempt to have the regexp do: + // - Avoid trailing '.' and ')' characters. We let our greedy match absorb + // these, but have a separate regex for extra characters to leave off at the + // end. + // + // The Regex (apart from lead-in/lead-out): + // ( Begin capture of the URL + // (?: (potential detect beginnings) + // https?:\/\/| Start with "http" or "https" + // www\d{0,3}[.][a-z0-9.\-]{2,249}| + // Start with "www", up to 3 numbers, then "." then + // something that looks domain-namey. We differ from the + // next case in that we do not constrain the top-level + // domain as tightly and do not require a trailing path + // indicator of "/". This is IDN root compatible. + // [a-z0-9.\-]{2,250}[.][a-z]{2,4}\/ + // Detect a non-www domain, but requiring a trailing "/" + // to indicate a path. This only detects IDN domains + // with a non-IDN root. This is reasonable in cases where + // there is no explicit http/https start us out, but + // unreasonable where there is. Our real fix is the bug + // to port the Thunderbird/gecko linkification logic. + // + // Domain names can be up to 253 characters long, and are + // limited to a-zA-Z0-9 and '-'. The roots don't have + // hyphens unless they are IDN roots. Root zones can be + // found here: http://www.iana.org/domains/root/db + // ) + // [-\w.!~*'();,/?:@&=+$#%]* + // path onwards. We allow the set of characters that + // encodeURI does not escape plus the result of escaping + // (so also '%') + // ) + // eslint-disable-next-line max-len + const urlRegex = + /(^|[\s(,;'"`“])((?:https?:\/(\/)?|www\d{0,3}[.][a-z0-9.\-]{2,249}|[a-z0-9.\-]{2,250}[.][a-z]{2,4}\/)[-\w.!~*'();,/?:@&=+$#%]*)/im; + + // Set of terminators that are likely to have been part of the context rather + // than part of the URL and so should be uneaten. This is '(', ',', ';', plus + // quotes and question end-ing punctuation and the potential permutations with + // parentheses (english-specific). + const uneatLastUrlCharsRegex = /(?:[),;.!?`'"]|[.!?]\)|\)[.!?])$/; + + const ELLIPSIS = "\u2026"; + const dom = require("devtools/client/shared/vendor/react-dom-factories"); + const { span } = dom; + + function escapeNewLines(value) { + return value.replace(/\r/gm, "\\r").replace(/\n/gm, "\\n"); + } + + // Map from character code to the corresponding escape sequence. \0 + // isn't here because it would require special treatment in some + // situations. \b, \f, and \v aren't here because they aren't very + // common. \' isn't here because there's no need, we only + // double-quote strings. + const escapeMap = { + // Tab. + 9: "\\t", + // Newline. + 0xa: "\\n", + // Carriage return. + 0xd: "\\r", + // Quote. + 0x22: '\\"', + // Backslash. + 0x5c: "\\\\", + }; + + // All characters we might possibly want to escape, excluding quotes. + // Note that we over-match here, because it's difficult to, say, match + // an unpaired surrogate with a regexp. The details are worked out by + // the replacement function; see |escapeString|. + const commonEscapes = + // Backslash. + "\\\\" + + // Controls. + "\x00-\x1f" + + // More controls. + "\x7f-\x9f" + + // BOM + "\ufeff" + + // Specials, except for the replacement character. + "\ufff0-\ufffc\ufffe\uffff" + + // Surrogates. + "\ud800-\udfff" + + // Mathematical invisibles. + "\u2061-\u2064" + + // Line and paragraph separators. + "\u2028-\u2029" + + // Private use area. + "\ue000-\uf8ff"; + const escapeRegexp = new RegExp(`[${commonEscapes}]`, "g"); + const escapeRegexpIncludingDoubleQuote = new RegExp( + `[${commonEscapes}"]`, + "g" + ); + + /** + * Escape a string so that the result is viewable and valid JS. + * Control characters, other invisibles, invalid characters, and backslash + * are escaped. The resulting string is quoted with either double quotes, + * single quotes, or backticks. The preference is for a quote that doesn't + * require escaping, falling back to double quotes if that's not possible + * (and then escaping them in the string). + * + * @param {String} str + * the input + * @param {Boolean} escapeWhitespace + * if true, TAB, CR, and NL characters will be escaped + * @return {String} the escaped string + */ + function escapeString(str, escapeWhitespace) { + let quote = '"'; + let regexp = escapeRegexp; + if (str.includes('"')) { + if (!str.includes("'")) { + quote = "'"; + } else if (!str.includes("`") && !str.includes("${")) { + quote = "`"; + } else { + regexp = escapeRegexpIncludingDoubleQuote; + } + } + return `${quote}${str.replace(regexp, (match, offset) => { + const c = match.charCodeAt(0); + if (c in escapeMap) { + if (!escapeWhitespace && (c === 9 || c === 0xa || c === 0xd)) { + return match[0]; + } + return escapeMap[c]; + } + if (c >= 0xd800 && c <= 0xdfff) { + // Find the full code point containing the surrogate, with a + // special case for a trailing surrogate at the start of the + // string. + if (c >= 0xdc00 && offset > 0) { + --offset; + } + const codePoint = str.codePointAt(offset); + if (codePoint >= 0xd800 && codePoint <= 0xdfff) { + // Unpaired surrogate. + return `\\u${codePoint.toString(16)}`; + } else if (codePoint >= 0xf0000 && codePoint <= 0x10fffd) { + // Private use area. Because we visit each pair of a such a + // character, return the empty string for one half and the + // real result for the other, to avoid duplication. + if (c <= 0xdbff) { + return `\\u{${codePoint.toString(16)}}`; + } + return ""; + } + // Other surrogate characters are passed through. + return match; + } + return `\\u${`0000${c.toString(16)}`.substr(-4)}`; + })}${quote}`; + } + + /** + * Escape a property name, if needed. "Escaping" in this context + * means surrounding the property name with quotes. + * + * @param {String} + * name the property name + * @return {String} either the input, or the input surrounded by + * quotes, properly quoted in JS syntax. + */ + function maybeEscapePropertyName(name) { + // Quote the property name if it needs quoting. This particular + // test is an approximation; see + // https://mathiasbynens.be/notes/javascript-properties. However, + // the full solution requires a fair amount of Unicode data, and so + // let's defer that until either it's important, or the \p regexp + // syntax lands, see + // https://github.com/tc39/proposal-regexp-unicode-property-escapes. + if (!/^\w+$/.test(name)) { + name = escapeString(name); + } + return name; + } + + function cropMultipleLines(text, limit) { + return escapeNewLines(cropString(text, limit)); + } + + function rawCropString(text, limit, alternativeText = ELLIPSIS) { + // Crop the string only if a limit is actually specified. + if (!limit || limit <= 0) { + return text; + } + + // Set the limit at least to the length of the alternative text + // plus one character of the original text. + if (limit <= alternativeText.length) { + limit = alternativeText.length + 1; + } + + const halfLimit = (limit - alternativeText.length) / 2; + + if (text.length > limit) { + return ( + text.substr(0, Math.ceil(halfLimit)) + + alternativeText + + text.substr(text.length - Math.floor(halfLimit)) + ); + } + + return text; + } + + function cropString(text, limit, alternativeText) { + return rawCropString(sanitizeString(`${text}`), limit, alternativeText); + } + + function sanitizeString(text) { + // Replace all non-printable characters, except of + // (horizontal) tab (HT: \x09) and newline (LF: \x0A, CR: \x0D), + // with unicode replacement character (u+fffd). + // eslint-disable-next-line no-control-regex + const re = new RegExp("[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]", "g"); + return text.replace(re, "\ufffd"); + } + + function parseURLParams(url) { + url = new URL(url); + return parseURLEncodedText(url.searchParams); + } + + function parseURLEncodedText(text) { + const params = []; + + // In case the text is empty just return the empty parameters + if (text == "") { + return params; + } + + const searchParams = new URLSearchParams(text); + const entries = [...searchParams.entries()]; + return entries.map(entry => { + return { + name: entry[0], + value: entry[1], + }; + }); + } + + function getFileName(url) { + const split = splitURLBase(url); + return split.name; + } + + function splitURLBase(url) { + if (!isDataURL(url)) { + return splitURLTrue(url); + } + return {}; + } + + function getURLDisplayString(url) { + return cropString(url); + } + + function isDataURL(url) { + return url && url.substr(0, 5) == "data:"; + } + + function splitURLTrue(url) { + const reSplitFile = /(.*?):\/{2,3}([^\/]*)(.*?)([^\/]*?)($|\?.*)/; + const m = reSplitFile.exec(url); + + if (!m) { + return { + name: url, + path: url, + }; + } else if (m[4] == "" && m[5] == "") { + return { + protocol: m[1], + domain: m[2], + path: m[3], + name: m[3] != "/" ? m[3] : m[2], + }; + } + + return { + protocol: m[1], + domain: m[2], + path: m[2] + m[3], + name: m[4] + m[5], + }; + } + + /** + * Wrap the provided render() method of a rep in a try/catch block that will + * render a fallback rep if the render fails. + */ + function wrapRender(renderMethod) { + const wrappedFunction = function (props) { + try { + return renderMethod.call(this, props); + } catch (e) { + console.error(e); + return span( + { + className: "objectBox objectBox-failure", + title: + "This object could not be rendered, " + + "please file a bug on bugzilla.mozilla.org", + }, + /* Labels have to be hardcoded for reps, see Bug 1317038. */ + "Invalid object" + ); + } + }; + wrappedFunction.propTypes = renderMethod.propTypes; + return wrappedFunction; + } + + /** + * Get preview items from a Grip. + * + * @param {Object} Grip from which we want the preview items + * @return {Array} Array of the preview items of the grip, or an empty array + * if the grip does not have preview items + */ + function getGripPreviewItems(grip) { + if (!grip) { + return []; + } + + // Array Grip + if (grip.preview && grip.preview.items) { + return grip.preview.items; + } + + // Node Grip + if (grip.preview && grip.preview.childNodes) { + return grip.preview.childNodes; + } + + // Set or Map Grip + if (grip.preview && grip.preview.entries) { + return grip.preview.entries.reduce((res, entry) => res.concat(entry), []); + } + + // Event Grip + if (grip.preview && grip.preview.target) { + const keys = Object.keys(grip.preview.properties); + const values = Object.values(grip.preview.properties); + return [grip.preview.target, ...keys, ...values]; + } + + // RegEx Grip + if (grip.displayString) { + return [grip.displayString]; + } + + // Generic Grip + if (grip.preview && grip.preview.ownProperties) { + let propertiesValues = Object.values(grip.preview.ownProperties).map( + property => property.value || property + ); + + const propertyKeys = Object.keys(grip.preview.ownProperties); + propertiesValues = propertiesValues.concat(propertyKeys); + + // ArrayBuffer Grip + if (grip.preview.safeGetterValues) { + propertiesValues = propertiesValues.concat( + Object.values(grip.preview.safeGetterValues).map( + property => property.getterValue || property + ) + ); + } + + return propertiesValues; + } + + return []; + } + + /** + * Get the type of an object. + * + * @param {Object} Grip from which we want the type. + * @param {boolean} noGrip true if the object is not a grip. + * @return {boolean} + */ + function getGripType(object, noGrip) { + if (noGrip || Object(object) !== object) { + return typeof object; + } + if (object.type === "object") { + return object.class; + } + return object.type; + } + + /** + * Determines whether a grip is a string containing a URL. + * + * @param string grip + * The grip, which may contain a URL. + * @return boolean + * Whether the grip is a string containing a URL. + */ + function containsURL(grip) { + // An URL can't be shorter than 5 char (e.g. "ftp:"). + if (typeof grip !== "string" || grip.length < 5) { + return false; + } + + return validProtocols.test(grip); + } + + /** + * Determines whether a string token is a valid URL. + * + * @param string token + * The token. + * @return boolean + * Whether the token is a URL. + */ + function isURL(token) { + try { + if (!validProtocols.test(token)) { + return false; + } + new URL(token); + return true; + } catch (e) { + return false; + } + } + + /** + * Returns new array in which `char` are interleaved between the original items. + * + * @param {Array} items + * @param {String} char + * @returns Array + */ + function interleave(items, char) { + return items.reduce((res, item, index) => { + if (index !== items.length - 1) { + return res.concat(item, char); + } + return res.concat(item); + }, []); + } + + const ellipsisElement = span( + { + key: "more", + className: "more-ellipsis", + title: `more${ELLIPSIS}`, + }, + ELLIPSIS + ); + + /** + * Removes any unallowed CSS properties from a string of CSS declarations + * + * @param {String} userProvidedStyle CSS declarations + * @param {Function} createElement Method to create a dummy element the styles get applied to + * @returns {Object} Filtered CSS properties as JavaScript object in camelCase notation + */ + function cleanupStyle(userProvidedStyle, createElement) { + // Regular expression that matches the allowed CSS property names. + const allowedStylesRegex = new RegExp( + "^(?:-moz-)?(?:align|background|border|box|clear|color|cursor|display|" + + "float|font|justify|line|margin|padding|position|text|transition" + + "|outline|vertical-align|white-space|word|writing|" + + "(?:min-|max-)?width|(?:min-|max-)?height)" + ); + + const mozElementRegex = /\b((?:-moz-)?element)[\s('"]+/gi; + + // Regex to retrieve usages of `url(*)` in property value + const cssUrlRegex = /url\([\'\"]?([^\)]*)/g; + + // Use a dummy element to parse the style string. + const dummy = createElement("div"); + dummy.style = userProvidedStyle; + + // Return a style object as expected by React DOM components, e.g. + // {color: "red"} + // without forbidden properties and values. + return Array.from(dummy.style) + .filter(name => { + if (!allowedStylesRegex.test(name)) { + return false; + } + + if (mozElementRegex.test(name)) { + return false; + } + + if (name === "position") { + return ["static", "relative"].includes( + dummy.style.getPropertyValue(name) + ); + } + // There can be multiple call to `url()` (e.g.` background: url("path/to/image"), url("data:image/png,…");`); + // filter out the property if the url function is called with anything that is not + // a data URL. + return Array.from(dummy.style[name].matchAll(cssUrlRegex)) + .map(match => match[1]) + .every(potentialUrl => potentialUrl.startsWith("data:")); + }) + .reduce((object, name) => { + // React requires CSS properties to be provided in JavaScript form, i.e. camelCased. + const jsName = name.replace(/-([a-z])/g, (_, char) => + char.toUpperCase() + ); + return Object.assign( + { + [jsName]: dummy.style.getPropertyValue(name), + }, + object + ); + }, {}); + } + + module.exports = { + interleave, + isURL, + cropString, + containsURL, + rawCropString, + sanitizeString, + escapeString, + wrapRender, + cropMultipleLines, + parseURLParams, + parseURLEncodedText, + getFileName, + getURLDisplayString, + maybeEscapePropertyName, + getGripPreviewItems, + getGripType, + ellipsisElement, + ELLIPSIS, + uneatLastUrlCharsRegex, + urlRegex, + cleanupStyle, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/rep.js b/devtools/client/shared/components/reps/reps/rep.js new file mode 100644 index 0000000000..8086a90b3e --- /dev/null +++ b/devtools/client/shared/components/reps/reps/rep.js @@ -0,0 +1,210 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Load all existing rep templates + const Undefined = require("devtools/client/shared/components/reps/reps/undefined"); + const Null = require("devtools/client/shared/components/reps/reps/null"); + const StringRep = require("devtools/client/shared/components/reps/reps/string"); + const Number = require("devtools/client/shared/components/reps/reps/number"); + const ArrayRep = require("devtools/client/shared/components/reps/reps/array"); + const Obj = require("devtools/client/shared/components/reps/reps/object"); + const SymbolRep = require("devtools/client/shared/components/reps/reps/symbol"); + const InfinityRep = require("devtools/client/shared/components/reps/reps/infinity"); + const NaNRep = require("devtools/client/shared/components/reps/reps/nan"); + const Accessor = require("devtools/client/shared/components/reps/reps/accessor"); + + // DOM types (grips) + const Accessible = require("devtools/client/shared/components/reps/reps/accessible"); + const Attribute = require("devtools/client/shared/components/reps/reps/attribute"); + const BigInt = require("devtools/client/shared/components/reps/reps/big-int"); + const DateTime = require("devtools/client/shared/components/reps/reps/date-time"); + const Document = require("devtools/client/shared/components/reps/reps/document"); + const DocumentType = require("devtools/client/shared/components/reps/reps/document-type"); + const Event = require("devtools/client/shared/components/reps/reps/event"); + const Func = require("devtools/client/shared/components/reps/reps/function"); + const PromiseRep = require("devtools/client/shared/components/reps/reps/promise"); + const RegExp = require("devtools/client/shared/components/reps/reps/regexp"); + const StyleSheet = require("devtools/client/shared/components/reps/reps/stylesheet"); + const CommentNode = require("devtools/client/shared/components/reps/reps/comment-node"); + const ElementNode = require("devtools/client/shared/components/reps/reps/element-node"); + const TextNode = require("devtools/client/shared/components/reps/reps/text-node"); + const ErrorRep = require("devtools/client/shared/components/reps/reps/error"); + const Window = require("devtools/client/shared/components/reps/reps/window"); + const ObjectWithText = require("devtools/client/shared/components/reps/reps/object-with-text"); + const ObjectWithURL = require("devtools/client/shared/components/reps/reps/object-with-url"); + const GripArray = require("devtools/client/shared/components/reps/reps/grip-array"); + const GripEntry = require("devtools/client/shared/components/reps/reps/grip-entry"); + const GripMap = require("devtools/client/shared/components/reps/reps/grip-map"); + const Grip = require("devtools/client/shared/components/reps/reps/grip"); + + // List of all registered template. + // XXX there should be a way for extensions to register a new + // or modify an existing rep. + const reps = [ + RegExp, + StyleSheet, + Event, + DateTime, + CommentNode, + Accessible, + ElementNode, + TextNode, + Attribute, + Func, + PromiseRep, + Document, + DocumentType, + Window, + ObjectWithText, + ObjectWithURL, + ErrorRep, + GripArray, + GripMap, + GripEntry, + Grip, + Undefined, + Null, + StringRep, + Number, + BigInt, + SymbolRep, + InfinityRep, + NaNRep, + Accessor, + ]; + + // Reps for rendering of native object reference (e.g. used from the JSONViewer, Netmonitor, …) + const noGripReps = [StringRep, Number, ArrayRep, Undefined, Null, Obj]; + + /** + * Generic rep that is used for rendering native JS types or an object. + * The right template used for rendering is picked automatically according + * to the current value type. The value must be passed in as the 'object' + * property. + */ + const Rep = function (props) { + const { object, defaultRep } = props; + const rep = getRep( + object, + defaultRep, + props.noGrip, + props.mayUseCustomFormatter + ); + return rep(props); + }; + + const exportedReps = { + Accessible, + Accessor, + ArrayRep, + Attribute, + BigInt, + CommentNode, + DateTime, + Document, + DocumentType, + ElementNode, + ErrorRep, + Event, + Func, + Grip, + GripArray, + GripMap, + GripEntry, + InfinityRep, + NaNRep, + Null, + Number, + Obj, + ObjectWithText, + ObjectWithURL, + PromiseRep, + RegExp, + Rep, + StringRep, + StyleSheet, + SymbolRep, + TextNode, + Undefined, + Window, + }; + + // Custom Formatters + // ToDo: This preference can be removed once the custom formatters feature is stable enough + // Services.prefs isn't available in jsonviewer. It doesn't matter as we don't want to use + // custom formatters there + if (typeof Services == "object" && Services?.prefs) { + const customFormattersExperimentallyEnabled = Services.prefs.getBoolPref( + "devtools.custom-formatters", + false + ); + + const useCustomFormatters = + customFormattersExperimentallyEnabled && + Services.prefs.getBoolPref("devtools.custom-formatters.enabled", false); + + if (useCustomFormatters) { + const CustomFormatter = require("devtools/client/shared/components/reps/reps/custom-formatter"); + reps.unshift(CustomFormatter); + exportedReps.CustomFormatter = CustomFormatter; + } + } + + // Helpers + + /** + * Return a rep object that is responsible for rendering given + * object. + * + * @param object {Object} Object to be rendered in the UI. This + * can be generic JS object as well as a grip (handle to a remote + * debuggee object). + * + * @param defaultRep {React.Component} The default template + * that should be used to render given object if none is found. + * + * @param noGrip {Boolean} If true, will only check reps not made for remote + * objects. + * + * @param mayUseCustomFormatter {Boolean} If true, custom formatters are + * allowed to be used as rep. + */ + function getRep( + object, + defaultRep = Grip, + noGrip = false, + mayUseCustomFormatter = false + ) { + const repsList = noGrip ? noGripReps : reps; + for (const rep of repsList) { + if (rep === exportedReps.CustomFormatter && !mayUseCustomFormatter) { + continue; + } + + try { + // supportsObject could return weight (not only true/false + // but a number), which would allow to priorities templates and + // support better extensibility. + if (rep.supportsObject(object, noGrip)) { + return rep.rep; + } + } catch (err) { + console.error(err); + } + } + + return defaultRep.rep; + } + + module.exports = { + Rep, + REPS: exportedReps, + // Exporting for tests + getRep, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/string.js b/devtools/client/shared/components/reps/reps/string.js new file mode 100644 index 0000000000..92539a9140 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/string.js @@ -0,0 +1,407 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const { + a, + span, + } = require("devtools/client/shared/vendor/react-dom-factories"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + const { + containsURL, + escapeString, + getGripType, + rawCropString, + sanitizeString, + wrapRender, + ELLIPSIS, + uneatLastUrlCharsRegex, + urlRegex, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders a string. String value is enclosed within quotes. + */ + + StringRep.propTypes = { + useQuotes: PropTypes.bool, + escapeWhitespace: PropTypes.bool, + style: PropTypes.object, + cropLimit: PropTypes.number.isRequired, + urlCropLimit: PropTypes.number, + member: PropTypes.object, + object: PropTypes.object.isRequired, + openLink: PropTypes.func, + className: PropTypes.string, + title: PropTypes.string, + isInContentPage: PropTypes.bool, + shouldRenderTooltip: PropTypes.bool, + }; + + function StringRep(props) { + const { + className, + style, + cropLimit, + urlCropLimit, + object, + useQuotes = true, + escapeWhitespace = true, + member, + openLink, + title, + isInContentPage, + transformEmptyString = false, + shouldRenderTooltip, + } = props; + + let text = object; + const config = getElementConfig({ + className, + style, + actor: object.actor, + title, + }); + + if (text == "" && transformEmptyString && !useQuotes) { + return span( + { + ...config, + title: "<empty string>", + className: `${config.className} objectBox-empty-string`, + }, + "<empty string>" + ); + } + + const isLong = isLongString(object); + const isOpen = member && member.open; + const shouldCrop = !isOpen && cropLimit && text.length > cropLimit; + + if (isLong) { + text = maybeCropLongString( + { + shouldCrop, + cropLimit, + }, + text + ); + + const { fullText } = object; + if (isOpen && fullText) { + text = fullText; + } + } + + text = formatText( + { + useQuotes, + escapeWhitespace, + }, + text + ); + + if (shouldRenderTooltip) { + config.title = text; + } + + if (!isLong) { + if (containsURL(text)) { + return span( + config, + getLinkifiedElements({ + text, + cropLimit: shouldCrop ? cropLimit : null, + urlCropLimit, + openLink, + isInContentPage, + }) + ); + } + + // Cropping of longString has been handled before formatting. + text = maybeCropString( + { + isLong, + shouldCrop, + cropLimit, + }, + text + ); + } + + return span(config, text); + } + + function maybeCropLongString(opts, object) { + const { shouldCrop, cropLimit } = opts; + + const grip = object && object.getGrip ? object.getGrip() : object; + const { initial, length } = grip; + + let text = shouldCrop ? initial.substring(0, cropLimit) : initial; + + if (text.length < length) { + text += ELLIPSIS; + } + + return text; + } + + function formatText(opts, text) { + const { useQuotes, escapeWhitespace } = opts; + + return useQuotes + ? escapeString(text, escapeWhitespace) + : sanitizeString(text); + } + + function getElementConfig(opts) { + const { className, style, actor, title } = opts; + + const config = {}; + + if (actor) { + config["data-link-actor-id"] = actor; + } + + if (title) { + config.title = title; + } + + const classNames = ["objectBox", "objectBox-string"]; + if (className) { + classNames.push(className); + } + config.className = classNames.join(" "); + + if (style) { + config.style = style; + } + + return config; + } + + function maybeCropString(opts, text) { + const { shouldCrop, cropLimit } = opts; + + return shouldCrop ? rawCropString(text, cropLimit) : text; + } + + /** + * Get an array of the elements representing the string, cropped if needed, + * with actual links. + * + * @param {Object} An options object of the following shape: + * - text {String}: The actual string to linkify. + * - cropLimit {Integer}: The limit to apply on the whole text. + * - urlCropLimit {Integer}: The limit to apply on each URL. + * - openLink {Function} openLink: Function handling the link + * opening. + * - isInContentPage {Boolean}: pass true if the reps is + * rendered in the content page + * (e.g. in JSONViewer). + * @returns {Array<String|ReactElement>} + */ + function getLinkifiedElements({ + text, + cropLimit, + urlCropLimit, + openLink, + isInContentPage, + }) { + const halfLimit = Math.ceil((cropLimit - ELLIPSIS.length) / 2); + const startCropIndex = cropLimit ? halfLimit : null; + const endCropIndex = cropLimit ? text.length - halfLimit : null; + + const items = []; + let currentIndex = 0; + let contentStart; + while (true) { + const url = urlRegex.exec(text); + // Pick the regexp with the earlier content; index will always be zero. + if (!url) { + break; + } + contentStart = url.index + url[1].length; + if (contentStart > 0) { + const nonUrlText = text.substring(0, contentStart); + items.push( + getCroppedString( + nonUrlText, + currentIndex, + startCropIndex, + endCropIndex + ) + ); + } + + // There are some final characters for a URL that are much more likely + // to have been part of the enclosing text rather than the end of the + // URL. + let useUrl = url[2]; + const uneat = uneatLastUrlCharsRegex.exec(useUrl); + if (uneat) { + useUrl = useUrl.substring(0, uneat.index); + } + + currentIndex = currentIndex + contentStart; + const linkText = getCroppedString( + useUrl, + currentIndex, + startCropIndex, + endCropIndex + ); + + if (linkText) { + const linkItems = []; + const shouldCrop = urlCropLimit && useUrl.length > urlCropLimit; + if (shouldCrop) { + const urlCropHalf = Math.ceil((urlCropLimit - ELLIPSIS.length) / 2); + // We cut the string into 3 elements and we'll visually hide the second one + // in CSS. This way people can still copy the full link. + linkItems.push( + span( + { className: "cropped-url-start" }, + useUrl.substring(0, urlCropHalf) + ), + span( + { className: "cropped-url-middle" }, + useUrl.substring(urlCropHalf, useUrl.length - urlCropHalf) + ), + span( + { className: "cropped-url-end" }, + useUrl.substring(useUrl.length - urlCropHalf) + ) + ); + } else { + linkItems.push(linkText); + } + + items.push( + a( + { + key: `${useUrl}-${currentIndex}`, + className: "url" + (shouldCrop ? " cropped-url" : ""), + title: useUrl, + draggable: false, + // Because we don't want the link to be open in the current + // panel's frame, we only render the href attribute if `openLink` + // exists (so we can preventDefault) or if the reps will be + // displayed in content page (e.g. in the JSONViewer). + href: openLink || isInContentPage ? useUrl : null, + target: "_blank", + rel: "noopener noreferrer", + onClick: openLink + ? e => { + e.preventDefault(); + openLink(useUrl, e); + } + : null, + }, + linkItems + ) + ); + } + + currentIndex = currentIndex + useUrl.length; + text = text.substring(url.index + url[1].length + useUrl.length); + } + + // Clean up any non-URL text at the end of the source string, + // i.e. not handled in the loop. + if (text.length) { + if (currentIndex < endCropIndex) { + text = getCroppedString( + text, + currentIndex, + startCropIndex, + endCropIndex + ); + } + items.push(text); + } + + return items; + } + + /** + * Returns a cropped substring given an offset, start and end crop indices in a + * parent string. + * + * @param {String} text: The substring to crop. + * @param {Integer} offset: The offset corresponding to the index at which + * the substring is in the parent string. + * @param {Integer|null} startCropIndex: the index where the start of the crop + * should happen in the parent string. + * @param {Integer|null} endCropIndex: the index where the end of the crop + * should happen in the parent string + * @returns {String|null} The cropped substring, or null if the text is + * completly cropped. + */ + function getCroppedString(text, offset = 0, startCropIndex, endCropIndex) { + if (!startCropIndex) { + return text; + } + + const start = offset; + const end = offset + text.length; + + const shouldBeVisible = !(start >= startCropIndex && end <= endCropIndex); + if (!shouldBeVisible) { + return null; + } + + const shouldCropEnd = start < startCropIndex && end > startCropIndex; + const shouldCropStart = start < endCropIndex && end > endCropIndex; + if (shouldCropEnd) { + const cutIndex = startCropIndex - start; + return ( + text.substring(0, cutIndex) + + ELLIPSIS + + (shouldCropStart ? text.substring(endCropIndex - start) : "") + ); + } + + if (shouldCropStart) { + // The string should be cropped at the beginning. + const cutIndex = endCropIndex - start; + return text.substring(cutIndex); + } + + return text; + } + + function isLongString(object) { + const grip = object && object.getGrip ? object.getGrip() : object; + return grip && grip.type === "longString"; + } + + function supportsObject(object, noGrip = false) { + // Accept the object if the grip-type (or type for noGrip objects) is "string" + if (getGripType(object, noGrip) == "string") { + return true; + } + + // Also accept longString objects if we're expecting grip + if (!noGrip) { + return isLongString(object); + } + + return false; + } + + // Exports from this module + + module.exports = { + rep: wrapRender(StringRep), + supportsObject, + isLongString, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/stylesheet.js b/devtools/client/shared/components/reps/reps/stylesheet.js new file mode 100644 index 0000000000..591442ebbf --- /dev/null +++ b/devtools/client/shared/components/reps/reps/stylesheet.js @@ -0,0 +1,78 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + getGripType, + getURLDisplayString, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders a grip representing CSSStyleSheet + */ + + StyleSheet.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function StyleSheet(props) { + const grip = props.object; + const shouldRenderTooltip = props.shouldRenderTooltip; + const location = getLocation(grip); + const config = getElementConfig({ grip, shouldRenderTooltip, location }); + + return span( + config, + getTitle(grip), + span({ className: "objectPropValue" }, location) + ); + } + + function getElementConfig(opts) { + const { grip, shouldRenderTooltip, location } = opts; + + return { + "data-link-actor-id": grip.actor, + className: "objectBox objectBox-object", + title: shouldRenderTooltip + ? `${getGripType(grip, false)} ${location}` + : null, + }; + } + + function getTitle(grip) { + return span( + { className: "objectBoxTitle" }, + `${getGripType(grip, false)} ` + ); + } + + function getLocation(grip) { + // Embedded stylesheets don't have URL and so, no preview. + const url = grip.preview ? grip.preview.url : ""; + return url ? getURLDisplayString(url) : ""; + } + + // Registration + function supportsObject(object, noGrip = false) { + return getGripType(object, noGrip) == "CSSStyleSheet"; + } + + // Exports from this module + + module.exports = { + rep: wrapRender(StyleSheet), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/symbol.js b/devtools/client/shared/components/reps/reps/symbol.js new file mode 100644 index 0000000000..89c76cdbb2 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/symbol.js @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + const { + rep: StringRep, + } = require("devtools/client/shared/components/reps/reps/string"); + + const MAX_STRING_LENGTH = 50; + + /** + * Renders a symbol. + */ + + SymbolRep.propTypes = { + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function SymbolRep(props) { + const { + className = "objectBox objectBox-symbol", + object, + shouldRenderTooltip, + } = props; + const { name } = object; + + let symbolText = name || ""; + if (name && name !== "Symbol.iterator" && name !== "Symbol.asyncIterator") { + symbolText = StringRep({ + object: symbolText, + shouldCrop: true, + cropLimit: MAX_STRING_LENGTH, + useQuotes: true, + }); + } + + const config = getElementConfig( + { + shouldRenderTooltip, + className, + name, + }, + object + ); + + return span(config, "Symbol(", symbolText, ")"); + } + + function getElementConfig(opts, object) { + const { shouldRenderTooltip, className, name } = opts; + + return { + "data-link-actor-id": object.actor, + className, + title: shouldRenderTooltip ? `Symbol(${name})` : null, + }; + } + + function supportsObject(object, noGrip = false) { + return getGripType(object, noGrip) == "symbol"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(SymbolRep), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/text-node.js b/devtools/client/shared/components/reps/reps/text-node.js new file mode 100644 index 0000000000..5df86030f3 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/text-node.js @@ -0,0 +1,136 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const { + button, + span, + } = require("devtools/client/shared/vendor/react-dom-factories"); + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + // Reps + const { + cropString, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + const { + rep: StringRep, + isLongString, + } = require("devtools/client/shared/components/reps/reps/string"); + + /** + * Renders DOM #text node. + */ + + TextNode.propTypes = { + object: PropTypes.object.isRequired, + mode: PropTypes.oneOf(Object.values(MODE)), + onDOMNodeMouseOver: PropTypes.func, + onDOMNodeMouseOut: PropTypes.func, + onInspectIconClick: PropTypes.func, + shouldRenderTooltip: PropTypes.bool, + }; + + function TextNode(props) { + const { object: grip, mode = MODE.SHORT } = props; + + const isInTree = grip.preview && grip.preview.isConnected === true; + const config = getElementConfig({ ...props, isInTree }); + const inspectIcon = getInspectIcon({ ...props, isInTree }); + + if (mode === MODE.TINY) { + return span(config, getTitle(grip), inspectIcon); + } + + return span( + config, + getTitle(grip), + " ", + StringRep({ + className: "nodeValue", + object: grip.preview.textContent, + }), + inspectIcon ? inspectIcon : null + ); + } + + function getElementConfig(opts) { + const { + object, + isInTree, + onDOMNodeMouseOver, + onDOMNodeMouseOut, + shouldRenderTooltip, + } = opts; + + const config = { + "data-link-actor-id": object.actor, + "data-link-content-dom-reference": JSON.stringify( + object.contentDomReference + ), + className: "objectBox objectBox-textNode", + title: shouldRenderTooltip ? `#text "${getTextContent(object)}"` : null, + }; + + if (isInTree) { + if (onDOMNodeMouseOver) { + Object.assign(config, { + onMouseOver: _ => onDOMNodeMouseOver(object), + }); + } + + if (onDOMNodeMouseOut) { + Object.assign(config, { + onMouseOut: _ => onDOMNodeMouseOut(object), + }); + } + } + + return config; + } + + function getTextContent(grip) { + const text = grip.preview.textContent; + return cropString(isLongString(text) ? text.initial : text); + } + + function getInspectIcon(opts) { + const { object, isInTree, onInspectIconClick } = opts; + + if (!isInTree || !onInspectIconClick) { + return null; + } + + return button({ + className: "open-inspector", + draggable: false, + // TODO: Localize this with "openNodeInInspector" when Bug 1317038 lands + title: "Click to select the node in the inspector", + onClick: e => onInspectIconClick(object, e), + }); + } + + function getTitle(grip) { + const title = "#text"; + return span({}, title); + } + + // Registration + function supportsObject(grip, noGrip = false) { + return grip?.preview && grip?.class == "Text"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(TextNode), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/undefined.js b/devtools/client/shared/components/reps/reps/undefined.js new file mode 100644 index 0000000000..7e0ac4c786 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/undefined.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // Dependencies + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + const { + getGripType, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + /** + * Renders undefined value + */ + + Undefined.propTypes = { + shouldRenderTooltip: PropTypes.bool, + }; + + function Undefined(props) { + const shouldRenderTooltip = props.shouldRenderTooltip; + + const config = getElementConfig(shouldRenderTooltip); + + return span(config, "undefined"); + } + + function getElementConfig(shouldRenderTooltip) { + return { + className: "objectBox objectBox-undefined", + title: shouldRenderTooltip ? "undefined" : null, + }; + } + + function supportsObject(object, noGrip = false) { + if (noGrip === true) { + return object === undefined; + } + + return ( + (object && object.type && object.type == "undefined") || + getGripType(object, noGrip) == "undefined" + ); + } + + // Exports from this module + + module.exports = { + rep: wrapRender(Undefined), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/reps/window.js b/devtools/client/shared/components/reps/reps/window.js new file mode 100644 index 0000000000..2c420c2b30 --- /dev/null +++ b/devtools/client/shared/components/reps/reps/window.js @@ -0,0 +1,102 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + // ReactJS + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + const { span } = require("devtools/client/shared/vendor/react-dom-factories"); + + // Reps + const { + getGripType, + getURLDisplayString, + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + + /** + * Renders a grip representing a window. + */ + + WindowRep.propTypes = { + mode: PropTypes.oneOf(Object.values(MODE)), + object: PropTypes.object.isRequired, + shouldRenderTooltip: PropTypes.bool, + }; + + function WindowRep(props) { + const { mode, object } = props; + + if (mode === MODE.TINY) { + const tinyTitle = getTitle(object); + const title = getTitle(object, true); + const location = getLocation(object); + const config = getElementConfig({ ...props, title, location }); + + return span( + config, + span({ className: tinyTitle.className }, tinyTitle.content) + ); + } + + const title = getTitle(object, true); + const location = getLocation(object); + const config = getElementConfig({ ...props, title, location }); + + return span( + config, + span({ className: title.className }, title.content), + span({ className: "location" }, location) + ); + } + + function getElementConfig(opts) { + const { object, shouldRenderTooltip, title, location } = opts; + let tooltip; + + if (location) { + tooltip = `${title.content}${location}`; + } else { + tooltip = `${title.content}`; + } + + return { + "data-link-actor-id": object.actor, + className: "objectBox objectBox-Window", + title: shouldRenderTooltip ? tooltip : null, + }; + } + + function getTitle(object, trailingSpace) { + let title = object.displayClass || object.class || "Window"; + if (trailingSpace === true) { + title = `${title} `; + } + return { + className: "objectTitle", + content: title, + }; + } + + function getLocation(object) { + return getURLDisplayString(object.preview.url); + } + + // Registration + function supportsObject(object, noGrip = false) { + return object?.preview && getGripType(object, noGrip) == "Window"; + } + + // Exports from this module + module.exports = { + rep: wrapRender(WindowRep), + supportsObject, + }; +}); diff --git a/devtools/client/shared/components/reps/shared/dom-node-constants.js b/devtools/client/shared/components/reps/shared/dom-node-constants.js new file mode 100644 index 0000000000..ecc1861d65 --- /dev/null +++ b/devtools/client/shared/components/reps/shared/dom-node-constants.js @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + module.exports = { + ELEMENT_NODE: 1, + ATTRIBUTE_NODE: 2, + TEXT_NODE: 3, + CDATA_SECTION_NODE: 4, + ENTITY_REFERENCE_NODE: 5, + ENTITY_NODE: 6, + PROCESSING_INSTRUCTION_NODE: 7, + COMMENT_NODE: 8, + DOCUMENT_NODE: 9, + DOCUMENT_TYPE_NODE: 10, + DOCUMENT_FRAGMENT_NODE: 11, + NOTATION_NODE: 12, + + // DocumentPosition + DOCUMENT_POSITION_DISCONNECTED: 0x01, + DOCUMENT_POSITION_PRECEDING: 0x02, + DOCUMENT_POSITION_FOLLOWING: 0x04, + DOCUMENT_POSITION_CONTAINS: 0x08, + DOCUMENT_POSITION_CONTAINED_BY: 0x10, + DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC: 0x20, + }; +}); diff --git a/devtools/client/shared/components/reps/shared/grip-length-bubble.js b/devtools/client/shared/components/reps/shared/grip-length-bubble.js new file mode 100644 index 0000000000..8d0dcb8bb8 --- /dev/null +++ b/devtools/client/shared/components/reps/shared/grip-length-bubble.js @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ + +"use strict"; + +// Make this available to both AMD and CJS environments +define(function (require, exports, module) { + const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); + + const { + wrapRender, + } = require("devtools/client/shared/components/reps/reps/rep-utils"); + const { + MODE, + } = require("devtools/client/shared/components/reps/reps/constants"); + const { + ModePropType, + } = require("devtools/client/shared/components/reps/reps/array"); + + const dom = require("devtools/client/shared/vendor/react-dom-factories"); + const { span } = dom; + + GripLengthBubble.propTypes = { + object: PropTypes.object.isRequired, + maxLengthMap: PropTypes.instanceOf(Map).isRequired, + getLength: PropTypes.func.isRequired, + mode: ModePropType, + visibilityThreshold: PropTypes.number, + }; + + function GripLengthBubble(props) { + const { + object, + mode = MODE.SHORT, + visibilityThreshold = 2, + maxLengthMap, + getLength, + showZeroLength = false, + } = props; + + const length = getLength(object); + const isEmpty = length === 0; + const isObvious = + [MODE.SHORT, MODE.LONG].includes(mode) && + length > 0 && + length <= maxLengthMap.get(mode) && + length <= visibilityThreshold; + if ((isEmpty && !showZeroLength) || isObvious) { + return ""; + } + + return span( + { + className: "objectLengthBubble", + }, + `(${length})` + ); + } + + module.exports = { + lengthBubble: wrapRender(GripLengthBubble), + }; +}); diff --git a/devtools/client/shared/components/reps/shared/moz.build b/devtools/client/shared/components/reps/shared/moz.build new file mode 100644 index 0000000000..6704491b97 --- /dev/null +++ b/devtools/client/shared/components/reps/shared/moz.build @@ -0,0 +1,10 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "dom-node-constants.js", + "grip-length-bubble.js", +) |