chrome-devtools-frontend
Version:
Chrome DevTools UI
1,235 lines (1,117 loc) • 77.4 kB
text/typescript
// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable @devtools/no-imperative-dom-api */
/*
* Copyright (C) 2008 Apple Inc. All Rights Reserved.
* Copyright (C) 2009 Joseph Pecoraro
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
* OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
import * as Common from '../../../../core/common/common.js';
import * as Host from '../../../../core/host/host.js';
import * as i18n from '../../../../core/i18n/i18n.js';
import * as Platform from '../../../../core/platform/platform.js';
import * as SDK from '../../../../core/sdk/sdk.js';
import type * as Protocol from '../../../../generated/protocol.js';
import * as TextUtils from '../../../../models/text_utils/text_utils.js';
import * as uiI18n from '../../../../ui/i18n/i18n.js';
import * as Highlighting from '../../../components/highlighting/highlighting.js';
import * as TextEditor from '../../../components/text_editor/text_editor.js';
import {Directives, html, type LitTemplate, nothing, render} from '../../../lit/lit.js';
import * as VisualLogging from '../../../visual_logging/visual_logging.js';
import * as UI from '../../legacy.js';
import type * as Components from '../utils/utils.js';
import {CustomPreviewComponent} from './CustomPreviewComponent.js';
import {JavaScriptREPL} from './JavaScriptREPL.js';
import objectPropertiesSectionStyles from './objectPropertiesSection.css.js';
import objectValueStyles from './objectValue.css.js';
import {RemoteObjectPreviewFormatter, renderNodeTitle} from './RemoteObjectPreviewFormatter.js';
export {objectPropertiesSectionStyles, objectValueStyles};
const {widget} = UI.Widget;
const {ref, repeat, ifDefined, classMap} = Directives;
const UIStrings = {
/**
* @description Text in Object Properties Section
* @example {function alert() [native code] } PH1
*/
exceptionS: '[Exception: {PH1}]',
/**
* @description Text in Object Properties Section
*/
unknown: 'unknown',
/**
* @description Text to expand something recursively
*/
expandRecursively: 'Expand recursively',
/**
* @description Text to collapse children of a parent group
*/
collapseChildren: 'Collapse children',
/**
* @description Text in Object Properties Section
*/
noProperties: 'No properties',
/**
* @description Element text content in Object Properties Section
*/
dots: '(...)',
/**
* @description Element title in Object Properties Section
*/
invokePropertyGetter: 'Invoke property getter',
/**
* @description Show all text content in Show More Data Grid Node of a data grid
* @example {50} PH1
*/
showAllD: 'Show all {PH1}',
/**
* @description Value element text content in Object Properties Section. Shown when the developer is
* viewing a variable in the Scope view, whose value is not available (i.e. because it was optimized
* out) by the JavaScript engine, or inspecting a JavaScript object accessor property, which has no
* getter. This string should be translated.
*/
valueUnavailable: '<value unavailable>',
/**
* @description Tooltip for value elements in the Scope view that refer to variables whose values
* aren't accessible to the debugger (potentially due to being optimized out by the JavaScript
* engine), or for JavaScript object accessor properties which have no getter.
*/
valueNotAccessibleToTheDebugger: 'Value is not accessible to the debugger',
/**
* @description A context menu item in the Watch Expressions Sidebar Pane of the Sources panel and Network pane request.
*/
copyValue: 'Copy value',
/**
* @description A context menu item in the Object Properties Section
*/
copyPropertyPath: 'Copy property path',
/**
* @description Text shown when displaying a JavaScript object that has a string property that is
* too large for DevTools to properly display a text editor. This is shown instead of the string in
* question. Should be translated.
*/
stringIsTooLargeToEdit: '<string is too large to edit>',
/**
* @description Text of attribute value when text is too long
* @example {30 MB} PH1
*/
showMoreS: 'Show more ({PH1})',
/**
* @description Text of attribute value when text is too long
* @example {30 MB} PH1
*/
longTextWasTruncatedS: 'long text was truncated ({PH1})',
/**
* @description Text for copying
*/
copy: 'Copy',
/**
* @description A tooltip text that shows when hovering over a button next to value objects,
* which are based on bytes and can be shown in a hexadecimal viewer.
* Clicking on the button will display that object in the Memory inspector panel.
*/
openInMemoryInpector: 'Open in Memory inspector panel',
} as const;
const str_ = i18n.i18n.registerUIStrings('ui/legacy/components/object_ui/ObjectPropertiesSection.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const EXPANDABLE_MAX_DEPTH = 100;
const objectPropertiesSectionMap = new WeakMap<Element, ObjectPropertiesSection>();
interface NodeChildren {
properties?: ObjectTreeNode[];
internalProperties?: ObjectTreeNode[];
arrayRanges?: ArrayGroupTreeNode[];
accessors?: ObjectTreeNode[];
}
export interface ObjectTreeOptions {
readonly propertiesMode: ObjectPropertiesMode;
readonly readOnly: boolean;
}
export abstract class ObjectTreeNodeBase extends Common.ObjectWrapper.ObjectWrapper<ObjectTreeNodeBase.EventTypes> {
#children?: NodeChildren;
protected filter: {includeNullOrUndefinedValues: boolean, regex: RegExp|null}|null = null;
protected extraProperties: ObjectTreeNode[] = [];
expanded = false;
constructor(readonly parent: ObjectTreeNodeBase|undefined, protected readonly options: ObjectTreeOptions) {
super();
this.filter = parent?.filter ?? null;
}
get readOnly(): boolean {
return this.options.readOnly;
}
get propertiesMode(): ObjectPropertiesMode {
return this.options.propertiesMode;
}
get includeNullOrUndefinedValues(): boolean {
return this.filter?.includeNullOrUndefinedValues ?? true;
}
set includeNullOrUndefinedValues(value: boolean) {
this.setFilter({includeNullOrUndefinedValues: value, regex: this.filter?.regex ?? null});
}
// Performs a pre-order tree traversal over the populated children. If any children need to be populated, callers must
// do that while walking (pre-order visitation enables that).
* #walk(maxDepth = -1): Generator<ObjectTreeNodeBase> {
function* walkChildren(children: ObjectTreeNodeBase[]|undefined): Generator<ObjectTreeNodeBase> {
if (children) {
for (const child of children) {
yield* child.#walk(Math.max(-1, maxDepth - 1));
}
}
}
yield this;
if (maxDepth !== 0) {
yield* walkChildren(this.#children?.properties);
yield* walkChildren(this.#children?.arrayRanges);
yield* walkChildren(this.#children?.internalProperties);
}
}
async expandRecursively(maxDepth: number): Promise<void> {
for (const node of this.#walk(maxDepth)) {
await node.populateChildrenIfNeeded();
node.expanded = true;
}
}
collapseRecursively(): void {
for (const node of this.#walk()) {
node.expanded = false;
}
}
setFilter(filter: {includeNullOrUndefinedValues: boolean, regex: RegExp|null}|null): void {
this.filter = filter;
this.dispatchEventToListeners(ObjectTreeNodeBase.Events.FILTER_CHANGED);
this.#walk().forEach(c => {
c.filter = filter;
c.dispatchEventToListeners(ObjectTreeNodeBase.Events.FILTER_CHANGED);
});
}
abstract get object(): SDK.RemoteObject.RemoteObject|undefined;
removeChildren(): void {
this.#children = undefined;
this.dispatchEventToListeners(ObjectTreeNodeBase.Events.CHILDREN_CHANGED);
}
removeChild(child: ObjectTreeNodeBase): void {
remove(this.#children?.arrayRanges, child);
remove(this.#children?.internalProperties, child);
remove(this.#children?.properties, child);
this.dispatchEventToListeners(ObjectTreeNodeBase.Events.CHILDREN_CHANGED);
function remove<T>(array: T[]|undefined, element: T): void {
if (!array) {
return;
}
const index = array.indexOf(element);
if (index >= 0) {
array.splice(index, 1);
}
}
}
protected selfOrParentIfInternal(): ObjectTreeNodeBase {
return this;
}
get children(): NodeChildren|undefined {
return this.#children;
}
async populateChildrenIfNeeded(): Promise<NodeChildren> {
if (!this.#children) {
this.#children = await this.populateChildrenIfNeededImpl();
}
return this.#children;
}
protected async populateChildrenIfNeededImpl(): Promise<NodeChildren> {
const object = this.object;
if (!object) {
return {};
}
const effectiveParent = this.selfOrParentIfInternal();
if (this.arrayLength > ARRAY_LOAD_THRESHOLD) {
const ranges = await arrayRangeGroups(object, 0, this.arrayLength - 1);
const arrayRanges = ranges?.ranges.map(
([fromIndex, toIndex, count]) => new ArrayGroupTreeNode(
object, {fromIndex, toIndex, count}, effectiveParent,
{readOnly: this.readOnly, propertiesMode: this.propertiesMode}));
if (!arrayRanges) {
return {};
}
const {properties: objectProperties, internalProperties: objectInternalProperties} =
await SDK.RemoteObject.RemoteObject.loadFromObjectPerProto(
this.object, true /* generatePreview */, true /* nonIndexedPropertiesOnly */);
const properties = objectProperties?.map(
p => new ObjectTreeNode(
p, effectiveParent,
{readOnly: this.readOnly, propertiesMode: ObjectPropertiesMode.OWN_AND_INTERNAL_AND_INHERITED}));
const internalProperties = objectInternalProperties?.map(
p => new ObjectTreeNode(
p, effectiveParent,
{readOnly: this.readOnly, propertiesMode: ObjectPropertiesMode.OWN_AND_INTERNAL_AND_INHERITED}));
return {arrayRanges, properties, internalProperties};
}
let objectProperties: SDK.RemoteObject.RemoteObjectProperty[]|null = null;
let objectInternalProperties: SDK.RemoteObject.RemoteObjectProperty[]|null = null;
switch (this.propertiesMode) {
case ObjectPropertiesMode.ALL:
({properties: objectProperties} =
await object.getAllProperties(false /* accessorPropertiesOnly */, true /* generatePreview */));
break;
case ObjectPropertiesMode.OWN_AND_INTERNAL_AND_INHERITED:
({properties: objectProperties, internalProperties: objectInternalProperties} =
await SDK.RemoteObject.RemoteObject.loadFromObjectPerProto(object, true /* generatePreview */));
break;
}
const properties = objectProperties?.map(
p => new ObjectTreeNode(
p, effectiveParent,
{readOnly: this.readOnly, propertiesMode: ObjectPropertiesMode.OWN_AND_INTERNAL_AND_INHERITED}));
properties?.push(...this.extraProperties);
properties?.sort(ObjectPropertiesSection.compareProperties);
const accessors = properties && ObjectTreeNodeBase.getGettersAndSetters(properties);
const internalProperties = objectInternalProperties?.map(
p => new ObjectTreeNode(
p, effectiveParent,
{readOnly: this.readOnly, propertiesMode: ObjectPropertiesMode.OWN_AND_INTERNAL_AND_INHERITED}));
return {properties, internalProperties, accessors};
}
get hasChildren(): boolean {
return this.object?.hasChildren ?? false;
}
get arrayLength(): number {
return this.object?.arrayLength() ?? 0;
}
// This is used in web tests
async setPropertyValue(name: string|Protocol.Runtime.CallArgument, value: string): Promise<string|undefined> {
return await this.object?.setPropertyValue(name, value);
}
addExtraProperties(...properties: SDK.RemoteObject.RemoteObjectProperty[]): void {
this.extraProperties.push(...properties.map(
p => new ObjectTreeNode(
p, this, {readOnly: this.readOnly, propertiesMode: ObjectPropertiesMode.OWN_AND_INTERNAL_AND_INHERITED})));
}
static getGettersAndSetters(properties: ObjectTreeNode[]): ObjectTreeNode[] {
const gettersAndSetters = [];
for (const property of properties) {
if (property.property.isOwn) {
if (property.property.getter) {
const getterProperty = new SDK.RemoteObject.RemoteObjectProperty(
'get ' + property.property.name, property.property.getter, false);
gettersAndSetters.push(new ObjectTreeNode(
getterProperty, property.parent, {propertiesMode: property.propertiesMode, readOnly: property.readOnly}));
}
if (property.property.setter) {
const setterProperty = new SDK.RemoteObject.RemoteObjectProperty(
'set ' + property.property.name, property.property.setter, false);
gettersAndSetters.push(new ObjectTreeNode(
setterProperty, property.parent, {propertiesMode: property.propertiesMode, readOnly: property.readOnly}));
}
}
}
return gettersAndSetters;
}
}
export namespace ObjectTreeNodeBase {
export const enum Events {
VALUE_CHANGED = 'value-changed',
CHILDREN_CHANGED = 'children-changed',
FILTER_CHANGED = 'filter-changed',
}
export interface EventTypes {
[Events.VALUE_CHANGED]: void;
[Events.CHILDREN_CHANGED]: void;
[Events.FILTER_CHANGED]: void;
}
}
export class ObjectTree extends ObjectTreeNodeBase {
readonly #object: SDK.RemoteObject.RemoteObject;
constructor(object: SDK.RemoteObject.RemoteObject, options: ObjectTreeOptions) {
super(undefined, options);
this.#object = object;
}
override get object(): SDK.RemoteObject.RemoteObject {
return this.#object;
}
}
class ArrayGroupTreeNode extends ObjectTreeNodeBase {
readonly #object: SDK.RemoteObject.RemoteObject;
readonly #range: {fromIndex: number, toIndex: number, count: number};
constructor(
object: SDK.RemoteObject.RemoteObject, range: {fromIndex: number, toIndex: number, count: number},
parent: ObjectTreeNodeBase, options: ObjectTreeOptions) {
super(parent, options);
this.#object = object;
this.#range = range;
}
override async populateChildrenIfNeededImpl(): Promise<NodeChildren> {
if (this.#range.count > ArrayGroupingTreeElement.bucketThreshold) {
const ranges = await arrayRangeGroups(this.object, this.#range.fromIndex, this.#range.toIndex);
const arrayRanges = ranges?.ranges.map(
([fromIndex, toIndex, count]) => new ArrayGroupTreeNode(
this.object, {fromIndex, toIndex, count}, this,
{readOnly: this.readOnly, propertiesMode: this.propertiesMode}));
return {arrayRanges};
}
const result = await this.#object.callFunction(buildArrayFragment, [
{value: this.#range.fromIndex},
{value: this.#range.toIndex},
{value: ArrayGroupingTreeElement.sparseIterationThreshold},
]);
if (!result.object || result.wasThrown) {
return {};
}
const arrayFragment = result.object;
const allProperties =
await arrayFragment.getAllProperties(false /* accessorPropertiesOnly */, true /* generatePreview */);
arrayFragment.release();
const properties = allProperties.properties?.map(
p => new ObjectTreeNode(p, this, {propertiesMode: this.propertiesMode, readOnly: this.readOnly}));
properties?.push(...this.extraProperties);
properties?.sort(ObjectPropertiesSection.compareProperties);
const accessors = properties && ObjectTreeNodeBase.getGettersAndSetters(properties);
return {properties, accessors};
}
get singular(): boolean {
return this.#range.fromIndex === this.#range.toIndex;
}
get range(): {fromIndex: number, toIndex: number, count: number} {
return this.#range;
}
override get object(): SDK.RemoteObject.RemoteObject {
return this.#object;
}
}
export class ObjectTreeNode extends ObjectTreeNodeBase {
#path?: string;
constructor(
readonly property: SDK.RemoteObject.RemoteObjectProperty,
parent: ObjectTreeNodeBase|undefined,
options: ObjectTreeOptions,
readonly nonSyntheticParent?: SDK.RemoteObject.RemoteObject,
) {
super(parent, options);
}
override get object(): SDK.RemoteObject.RemoteObject|undefined {
return this.property.value;
}
get isFiltered(): boolean {
return Boolean(this.filter && !this.property.match(this.filter));
}
get name(): string {
return this.property.name;
}
get path(): string {
if (!this.#path) {
if (this.property.synthetic) {
this.#path = this.name;
return this.name;
}
// https://tc39.es/ecma262/#prod-IdentifierName
const useDotNotation = /^(?:[$_\p{ID_Start}])(?:[$_\u200C\u200D\p{ID_Continue}])*$/u;
const isInteger = /^(?:0|[1-9]\d*)$/;
const parentPath =
(this.parent instanceof ObjectTreeNode && !this.parent.property.synthetic) ? this.parent.path : '';
if (this.property.private || useDotNotation.test(this.name)) {
this.#path = parentPath ? `${parentPath}.${this.name}` : this.name;
} else if (isInteger.test(this.name)) {
this.#path = `${parentPath}[${this.name}]`;
} else {
this.#path = `${parentPath}[${JSON.stringify(this.name)}]`;
}
}
return this.#path;
}
override selfOrParentIfInternal(): ObjectTreeNodeBase {
return this.name === '[[Prototype]]' ? (this.parent ?? this) : this;
}
async setValue(expression: string): Promise<void> {
const property = SDK.RemoteObject.RemoteObject.toCallArgument(this.property.symbol || this.name);
expression = JavaScriptREPL.wrapObjectLiteral(expression.trim());
if (this.property.synthetic) {
let invalidate = false;
if (expression) {
invalidate = await this.property.setSyntheticValue(expression);
}
if (invalidate) {
this.parent?.removeChildren();
} else {
this.dispatchEventToListeners(ObjectTreeNodeBase.Events.VALUE_CHANGED);
}
return;
}
const parentObject = this.parent?.object as SDK.RemoteObject.RemoteObject;
const errorPromise =
expression ? parentObject.setPropertyValue(property, expression) : parentObject.deleteProperty(property);
const error = await errorPromise;
if (error) {
this.dispatchEventToListeners(ObjectTreeNodeBase.Events.VALUE_CHANGED);
return;
}
if (!expression) {
this.parent?.removeChild(this);
} else {
this.parent?.removeChildren();
}
}
async invokeGetter(getter: SDK.RemoteObject.RemoteObject): Promise<void> {
const invokeGetter = `
function invokeGetter(getter) {
return Reflect.apply(getter, this, []);
}`;
// Also passing a string instead of a Function to avoid coverage implementation messing with it.
const result = await this.parent
?.object
// @ts-expect-error No way to teach TypeScript to preserve the Function-ness of `getter`.
?.callFunction(invokeGetter, [SDK.RemoteObject.RemoteObject.toCallArgument(getter)]);
if (!result?.object) {
return;
}
this.property.value = result.object;
this.property.wasThrown = result.wasThrown || false;
this.dispatchEventToListeners(ObjectTreeNodeBase.Events.VALUE_CHANGED);
}
}
export const getObjectPropertiesSectionFrom = (element: Element): ObjectPropertiesSection|undefined => {
return objectPropertiesSectionMap.get(element);
};
export class ObjectPropertiesSection extends UI.TreeOutline.TreeOutlineInShadow {
readonly root: ObjectTree;
readonly #objectTreeElement: RootElement;
titleElement: Element;
skipProtoInternal?: boolean;
constructor(
object: SDK.RemoteObject.RemoteObject, title?: string|Element|null, linkifier?: Components.Linkifier.Linkifier,
showOverflow?: boolean, editable = true) {
super();
this.root = new ObjectTree(
object, {readOnly: !editable, propertiesMode: ObjectPropertiesMode.OWN_AND_INTERNAL_AND_INHERITED});
if (!showOverflow) {
this.setHideOverflow(true);
}
this.setFocusable(true);
this.setShowSelectionOnKeyboardFocus(true);
this.#objectTreeElement = new RootElement(this.root, linkifier);
this.appendChild(this.#objectTreeElement);
if (typeof title === 'string' || !title) {
this.titleElement = this.element.createChild('span');
this.titleElement.textContent = title || '';
} else {
this.titleElement = title;
this.element.appendChild(title);
}
if (this.titleElement instanceof HTMLElement && !this.titleElement.hasAttribute('tabIndex')) {
this.titleElement.tabIndex = -1;
}
objectPropertiesSectionMap.set(this.element, this);
this.registerRequiredCSS(objectValueStyles, objectPropertiesSectionStyles);
this.rootElement().childrenListElement.classList.add('source-code', 'object-properties-section');
}
static defaultObjectPresentation(
object: SDK.RemoteObject.RemoteObject, linkifier?: Components.Linkifier.Linkifier, skipProto?: boolean,
readOnly?: boolean): Element {
const objectPropertiesSection =
ObjectPropertiesSection.defaultObjectPropertiesSection(object, linkifier, skipProto, readOnly);
if (!object.hasChildren) {
return objectPropertiesSection.titleElement;
}
return objectPropertiesSection.element;
}
static defaultObjectPropertiesSection(
object: SDK.RemoteObject.RemoteObject, linkifier?: Components.Linkifier.Linkifier, skipProto?: boolean,
readOnly?: boolean): ObjectPropertiesSection {
const titleElement = document.createElement('span');
titleElement.classList.add('source-code');
const shadowRoot = UI.UIUtils.createShadowRootWithCoreStyles(titleElement, {cssFile: objectValueStyles});
const propertyValue =
ObjectPropertiesSection.createPropertyValue(object, /* wasThrown */ false, /* showPreview */ true);
shadowRoot.appendChild(propertyValue);
const objectPropertiesSection = new ObjectPropertiesSection(object, titleElement, linkifier, undefined, !readOnly);
if (skipProto) {
objectPropertiesSection.skipProto();
}
return objectPropertiesSection;
}
// The RemoteObjectProperty overload is kept for web test compatibility for now.
static compareProperties(
propertyA: ObjectTreeNode|SDK.RemoteObject.RemoteObjectProperty,
propertyB: ObjectTreeNode|SDK.RemoteObject.RemoteObjectProperty): number {
if (propertyA instanceof ObjectTreeNode) {
propertyA = propertyA.property;
}
if (propertyB instanceof ObjectTreeNode) {
propertyB = propertyB.property;
}
if (!propertyA.synthetic && propertyB.synthetic) {
return 1;
}
if (!propertyB.synthetic && propertyA.synthetic) {
return -1;
}
if (!propertyA.isOwn && propertyB.isOwn) {
return 1;
}
if (!propertyB.isOwn && propertyA.isOwn) {
return -1;
}
if (!propertyA.enumerable && propertyB.enumerable) {
return 1;
}
if (!propertyB.enumerable && propertyA.enumerable) {
return -1;
}
if (propertyA.symbol && !propertyB.symbol) {
return 1;
}
if (propertyB.symbol && !propertyA.symbol) {
return -1;
}
if (propertyA.private && !propertyB.private) {
return 1;
}
if (propertyB.private && !propertyA.private) {
return -1;
}
const a = propertyA.name;
const b = propertyB.name;
if (a.startsWith('_') && !b.startsWith('_')) {
return 1;
}
if (b.startsWith('_') && !a.startsWith('_')) {
return -1;
}
return Platform.StringUtilities.naturalOrderComparator(a, b);
}
static createNameElement(name: string|null, isPrivate?: boolean): Element {
const element = document.createElement('span');
element.classList.add('name');
if (name === null) {
return element;
}
if (/^\s|\s$|^$|\n/.test(name)) {
element.textContent = `"${name.replace(/\n/g, '\u21B5')}"`;
return element;
}
if (isPrivate) {
const privatePropertyHash = document.createElement('span');
privatePropertyHash.classList.add('private-property-hash');
privatePropertyHash.textContent = name[0];
element.appendChild(privatePropertyHash);
element.appendChild(document.createTextNode(name.substring(1)));
return element;
}
element.textContent = name;
return element;
}
static valueElementForFunctionDescription(
description?: string, includePreview?: boolean, defaultName?: string, className?: string): LitTemplate {
const contents =
(description: string, defaultName: string): {prefix: string, abbreviation: string, body: string} => {
const text = description.replace(/^function [gs]et /, 'function ')
.replace(/^function [gs]et\(/, 'function\(')
.replace(/^[gs]et /, '');
// This set of best-effort regular expressions captures common function descriptions.
// Ideally, some parser would provide prefix, arguments, function body text separately.
const asyncMatch = text.match(/^(async\s+function)/);
const isGenerator = text.startsWith('function*');
const isGeneratorShorthand = text.startsWith('*');
const isBasic = !isGenerator && text.startsWith('function');
const isClass = text.startsWith('class ') || text.startsWith('class{');
const firstArrowIndex = text.indexOf('=>');
const isArrow = !asyncMatch && !isGenerator && !isBasic && !isClass && firstArrowIndex > 0;
if (isClass) {
const body = text.substring('class'.length);
const classNameMatch = /^[^{\s]+/.exec(body.trim());
let className: string = defaultName;
if (classNameMatch) {
className = classNameMatch[0].trim() || defaultName;
}
return {prefix: 'class', body, abbreviation: className};
}
if (asyncMatch) {
const body = text.substring(asyncMatch[1].length);
return {prefix: 'async \u0192', body, abbreviation: nameAndArguments(body)};
}
if (isGenerator) {
const body = text.substring('function*'.length);
return {prefix: '\u0192*', body, abbreviation: nameAndArguments(body)};
}
if (isGeneratorShorthand) {
const body = text.substring('*'.length);
return {prefix: '\u0192*', body, abbreviation: nameAndArguments(body)};
}
if (isBasic) {
const body = text.substring('function'.length);
return {prefix: '\u0192', body, abbreviation: nameAndArguments(body)};
}
if (isArrow) {
const maxArrowFunctionCharacterLength = 60;
let abbreviation: string = text;
if (defaultName) {
abbreviation = defaultName + '()';
} else if (text.length > maxArrowFunctionCharacterLength) {
abbreviation = text.substring(0, firstArrowIndex + 2) + ' {…}';
}
return {prefix: '', body: text, abbreviation};
}
return {prefix: '\u0192', body: text, abbreviation: nameAndArguments(text)};
};
const {prefix, body, abbreviation} = contents(description ?? '', defaultName ?? '');
const maxFunctionBodyLength = 200;
return html`<span
class="object-value-function ${className ?? ''}"
title=${Platform.StringUtilities.trimEndWithMaxLength(description ?? '', 500)}>${
prefix && html`<span class=object-value-function-prefix>${prefix} </span>`}${
includePreview ? Platform.StringUtilities.trimEndWithMaxLength(body.trim(), maxFunctionBodyLength) :
abbreviation.replace(/\n/g, ' ')}</span>`;
function nameAndArguments(contents: string): string {
const startOfArgumentsIndex = contents.indexOf('(');
const endOfArgumentsMatch = contents.match(/\)\s*{/);
if (startOfArgumentsIndex !== -1 && endOfArgumentsMatch?.index !== undefined &&
endOfArgumentsMatch.index > startOfArgumentsIndex) {
const name = contents.substring(0, startOfArgumentsIndex).trim() || (defaultName ?? '');
const args = contents.substring(startOfArgumentsIndex, endOfArgumentsMatch.index + 1);
return name + args;
}
return defaultName + '()';
}
}
static createPropertyValueWithCustomSupport(
value: SDK.RemoteObject.RemoteObject, wasThrown: boolean, showPreview: boolean,
linkifier?: Components.Linkifier.Linkifier, isSyntheticProperty?: boolean, variableName?: string,
includeNullOrUndefined?: boolean): HTMLElement {
if (value.customPreview()) {
const result = (new CustomPreviewComponent(value)).element;
result.classList.add('object-properties-section-custom-section');
return result;
}
return ObjectPropertiesSection.createPropertyValue(
value, wasThrown, showPreview, linkifier, isSyntheticProperty, variableName, includeNullOrUndefined);
}
static getMemoryIcon(object: SDK.RemoteObject.RemoteObject, expression?: string): LitTemplate {
// Directly set styles on memory icon, so that the memory icon is also
// styled within the context of code mirror.
// clang-format off
return !object.isLinearMemoryInspectable() ? nothing : html`<devtools-icon
name=memory
style="width: var(--sys-size-8); height: 13px; vertical-align: sub; cursor: pointer;"
@click=${(event: Event) => {
event.consume();
void Common.Revealer.reveal(new SDK.RemoteObject.LinearMemoryInspectable(object, expression));
}}
jslog=${VisualLogging.action('open-memory-inspector').track({click: true})}
title=${i18nString(UIStrings.openInMemoryInpector)}
aria-label=${i18nString(UIStrings.openInMemoryInpector)}></devtools-icon>`;
// clang-format on
}
static appendMemoryIcon(element: Element, object: SDK.RemoteObject.RemoteObject, expression?: string): void {
const fragment = document.createDocumentFragment();
// eslint-disable-next-line @devtools/no-lit-render-outside-of-view
render(ObjectPropertiesSection.getMemoryIcon(object, expression), fragment);
element.appendChild(fragment);
}
static createPropertyValue(
value: SDK.RemoteObject.RemoteObject, wasThrown: boolean, showPreview: boolean,
linkifier?: Components.Linkifier.Linkifier, isSyntheticProperty = false, variableName?: string,
includeNullOrUndefined?: boolean): HTMLElement {
const propertyValue = document.createDocumentFragment();
const type = value.type;
const subtype = value.subtype;
const description = value.description || '';
const className = value.className;
const contents = (): LitTemplate => {
if (type === 'object' && subtype === 'internal#location') {
const rawLocation = value.debuggerModel().createRawLocationByScriptId(
value.value.scriptId, value.value.lineNumber, value.value.columnNumber);
if (rawLocation && linkifier) {
return html`${linkifier.linkifyRawLocation(rawLocation, Platform.DevToolsPath.EmptyUrlString, 'value')}`;
}
return html`<span class=value title=${description}>${'<' + i18nString(UIStrings.unknown) + '>'}</span>`;
}
if (type === 'string' && typeof description === 'string') {
const text = JSON.stringify(description);
const tooLong = description.length > maxRenderableStringLength;
return html`<span class="value object-value-string" title=${ifDefined(tooLong ? undefined : description)}>${
tooLong ? widget(ExpandableTextPropertyValue, {text}) : text}</span>`;
}
if (type === 'object' && subtype === 'trustedtype') {
const text = `${className} '${description}'`;
const tooLong = text.length > maxRenderableStringLength;
return html`<span class="value object-value-trustedtype" title=${ifDefined(tooLong ? undefined : text)}>${
tooLong ? widget(ExpandableTextPropertyValue, {text}) :
html`${className} <span class=object-value-string title=${description}>${
JSON.stringify(description)}</span>`}</span>`;
}
if (type === 'function') {
return ObjectPropertiesSection.valueElementForFunctionDescription(description, undefined, undefined, 'value');
}
if (type === 'object' && subtype === 'node' && description) {
return html`<span class="value object-value-node"
@click=${(event: Event) => {
void Common.Revealer.reveal(value);
event.consume(true);
}}
@mousemove=${() => SDK.OverlayModel.OverlayModel.highlightObjectAsDOMNode(value)}
@mouseleave=${() => SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight()}
>${renderNodeTitle(description)}</span>`;
}
if (description.length > maxRenderableStringLength) {
// clang-format off
return html`<span class="value object-value-${subtype || type}" title=${description}>
${widget(ExpandableTextPropertyValue, {text: description})}
</span>`;
// clang-format on
}
const hasPreview = value.preview && showPreview;
return html`<span class="value object-value-${subtype || type}" title=${description}>${
hasPreview ? new RemoteObjectPreviewFormatter().renderObjectPreview(value.preview, includeNullOrUndefined) :
description}${isSyntheticProperty ? nothing : this.getMemoryIcon(value, variableName)}</span>`;
};
if (wasThrown) {
// eslint-disable-next-line @devtools/no-lit-render-outside-of-view
render(
html`<span class="error value">${
uiI18n.getFormatLocalizedStringTemplate(str_, UIStrings.exceptionS, {PH1: contents()})}</span>`,
propertyValue);
} else {
// eslint-disable-next-line @devtools/no-lit-render-outside-of-view
render(contents(), propertyValue);
}
const child = propertyValue.firstElementChild;
if (!(child instanceof HTMLElement)) {
throw new Error('Expected an HTML element');
}
return child;
}
static formatObjectAsFunction(
func: SDK.RemoteObject.RemoteObject, element: Element, linkify: boolean,
includePreview?: boolean): Promise<void> {
return func.debuggerModel().functionDetailsPromise(func).then(didGetDetails);
function didGetDetails(response: SDK.DebuggerModel.FunctionDetails|null): void {
if (linkify && response?.location) {
element.classList.add('linkified');
element.addEventListener('click', () => {
void Common.Revealer.reveal(response.location);
return false;
});
}
// The includePreview flag is false for formats such as console.dir().
let defaultName: string|('' | 'anonymous') = includePreview ? '' : 'anonymous';
if (response?.functionName) {
defaultName = response.functionName;
}
const valueElement = document.createDocumentFragment();
// eslint-disable-next-line @devtools/no-lit-render-outside-of-view
render(
ObjectPropertiesSection.valueElementForFunctionDescription(func.description, includePreview, defaultName),
valueElement);
element.appendChild(valueElement);
}
}
static isDisplayableProperty(
property: SDK.RemoteObject.RemoteObjectProperty,
parentProperty?: SDK.RemoteObject.RemoteObjectProperty): boolean {
if (!parentProperty?.synthetic) {
return true;
}
const name = property.name;
const useless = (parentProperty.name === '[[Entries]]' && (name === 'length' || name === '__proto__'));
return !useless;
}
skipProto(): void {
this.skipProtoInternal = true;
}
expand(): void {
this.#objectTreeElement.expand();
}
objectTreeElement(): UI.TreeOutline.TreeElement {
return this.#objectTreeElement;
}
enableContextMenu(): void {
this.element.addEventListener('contextmenu', this.contextMenuEventFired.bind(this), false);
}
private contextMenuEventFired(event: Event): void {
const contextMenu = new UI.ContextMenu.ContextMenu(event);
contextMenu.appendApplicableItems(this.root);
if (this.root.object instanceof SDK.RemoteObject.LocalJSONObject) {
contextMenu.viewSection().appendItem(
i18nString(UIStrings.expandRecursively),
this.#objectTreeElement.expandRecursively.bind(this.#objectTreeElement, EXPANDABLE_MAX_DEPTH),
{jslogContext: 'expand-recursively'});
contextMenu.viewSection().appendItem(
i18nString(UIStrings.collapseChildren),
this.#objectTreeElement.collapseChildren.bind(this.#objectTreeElement), {jslogContext: 'collapse-children'});
}
void contextMenu.show();
}
titleLessMode(): void {
this.#objectTreeElement.listItemElement.classList.add('hidden');
this.#objectTreeElement.childrenListElement.classList.add('title-less-mode');
this.#objectTreeElement.expand();
}
}
/** @constant */
const ARRAY_LOAD_THRESHOLD = 100;
const maxRenderableStringLength = 10000;
export interface TreeOutlineOptions {
readOnly?: boolean;
}
export class ObjectPropertiesSectionsTreeOutline extends UI.TreeOutline.TreeOutlineInShadow {
constructor() {
super();
this.registerRequiredCSS(objectValueStyles, objectPropertiesSectionStyles);
this.contentElement.classList.add('source-code');
this.contentElement.classList.add('object-properties-section');
}
}
export const enum ObjectPropertiesMode {
ALL = 0, // All properties, including prototype properties
OWN_AND_INTERNAL_AND_INHERITED = 1, // Own, internal, and inherited properties
}
export class RootElement extends UI.TreeOutline.TreeElement {
private readonly object: ObjectTree;
private readonly linkifier: Components.Linkifier.Linkifier|undefined;
private readonly emptyPlaceholder: string|null|undefined;
override toggleOnClick: boolean;
constructor(object: ObjectTree, linkifier?: Components.Linkifier.Linkifier, emptyPlaceholder?: string|null) {
const contentElement = document.createElement('slot');
super(contentElement);
this.object = object;
this.object.addEventListener(ObjectTreeNodeBase.Events.CHILDREN_CHANGED, this.onpopulate, this);
this.linkifier = linkifier;
this.emptyPlaceholder = emptyPlaceholder;
this.setExpandable(true);
this.selectable = true;
this.toggleOnClick = true;
this.listItemElement.classList.add('object-properties-section-root-element');
this.listItemElement.addEventListener('contextmenu', this.onContextMenu.bind(this), false);
}
override onexpand(): void {
if (this.treeOutline) {
this.treeOutline.element.classList.add('expanded');
}
}
override oncollapse(): void {
if (this.treeOutline) {
this.treeOutline.element.classList.remove('expanded');
}
}
override ondblclick(_e: Event): boolean {
return true;
}
private onContextMenu(event: Event): void {
const contextMenu = new UI.ContextMenu.ContextMenu(event);
contextMenu.appendApplicableItems(this.object.object);
if (this.object instanceof SDK.RemoteObject.LocalJSONObject) {
const {value} = this.object;
const propertyValue = typeof value === 'object' ? JSON.stringify(value, null, 2) : value;
const copyValueHandler = (): void => {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.NetworkPanelCopyValue);
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText((propertyValue as string | undefined));
};
contextMenu.clipboardSection().appendItem(
i18nString(UIStrings.copyValue), copyValueHandler, {jslogContext: 'copy-value'});
}
contextMenu.viewSection().appendItem(
i18nString(UIStrings.expandRecursively), this.expandRecursively.bind(this, EXPANDABLE_MAX_DEPTH),
{jslogContext: 'expand-recursively'});
contextMenu.viewSection().appendItem(
i18nString(UIStrings.collapseChildren), this.collapseChildren.bind(this), {jslogContext: 'collapse-children'});
contextMenu.viewSection().appendCheckboxItem(i18n.i18n.lockedString('Show all'), () => {
this.object.includeNullOrUndefinedValues = !this.object.includeNullOrUndefinedValues;
}, {checked: this.object.includeNullOrUndefinedValues, jslogContext: 'show-all'});
void contextMenu.show();
}
override async onpopulate(): Promise<void> {
this.removeChildren();
const treeOutline = (this.treeOutline as ObjectPropertiesSection | null);
const skipProto = treeOutline ? Boolean(treeOutline.skipProtoInternal) : false;
return await ObjectPropertyTreeElement.populate(
this, this.object, skipProto, false, this.linkifier, this.emptyPlaceholder);
}
}
/**
* Number of initially visible children in an ObjectPropertyTreeElement.
* Remaining children are shown as soon as requested via a show more properties button.
**/
export const InitialVisibleChildrenLimit = 200;
export interface ObjectPropertyViewInput {
editable: boolean;
startEditing(): unknown;
invokeGetter(getter: SDK.RemoteObject.RemoteObject): unknown;
onAutoComplete(expression: string, filter: string, force: boolean): unknown;
linkifier: Components.Linkifier.Linkifier|undefined;
completions: string[];
expanded: boolean;
editing: boolean;
editingEnded(): unknown;
editingCommitted(detail: string): unknown;
node: ObjectTreeNode;
}
interface ObjectPropertyViewOutput {
valueElement: Element|undefined;
nameElement: Element|undefined;
}
type ObjectPropertyView = (input: ObjectPropertyViewInput, output: ObjectPropertyViewOutput, target: HTMLElement) =>
void;
export const OBJECT_PROPERTY_DEFAULT_VIEW: ObjectPropertyView = (input, output, target) => {
const {property} = input.node;
const isInternalEntries = property.synthetic && input.node.name === '[[Entries]]';
const completionsId = `completions-${input.node.parent?.object?.objectId?.replaceAll('.', '-')}-${input.node.name}`;
const onAutoComplete = async(e: UI.TextPrompt.TextPromptElement.BeforeAutoCompleteEvent): Promise<void> => {
if (!(e.target instanceof UI.TextPrompt.TextPromptElement)) {
return;
}
input.onAutoComplete(e.detail.expression, e.detail.filter, e.detail.force);
};
const nameClasses = classMap({
name: true,
'object-properties-section-dimmed': !property.enumerable,
'own-property': property.isOwn,
'synthetic-property': property.synthetic,
});
const quotedName =
/^\s|\s$|^$|\n/.test(property.name) ? `"${property.name.replace(/\n/g, '\u21B5')}"` : property.name;
const isExpandable = !isInternalEntries && property.value && !property.wasThrown && property.value.hasChildren &&
!property.value.customPreview() && property.value.subtype !== 'node' && property.value.type !== 'function' &&
(property.value.type !== 'object' || property.value.preview);
const value = (): LitTemplate|HTMLElement => {
const valueRef = ref(e => {
output.valueElement = e;
});
if (isInternalEntries) {
return html`<span ${valueRef} class=value></span>`;
}
if (property.value) {
const showPreview = property.name !== '[[Prototype]]';
const value = ObjectPropertiesSection.createPropertyValueWithCustomSupport(
property.value, property.wasThrown, showPreview, input.linkifier, property.synthetic,
input.node.path /* variableName */, input.node.includeNullOrUndefinedValues);
output.valueElement = value;
return value;
}
if (property.getter) {
const getter = property.getter;
const invokeGetter = (event: Event): void => {
event.consume();
input.invokeGetter(getter);
};
return html`<span ${valueRef}><span
class=object-value-calculate-value-button
title=${i18nString(UIStrings.invokePropertyGetter)}
@click=${invokeGetter}
>${i18nString(UIStrings.dots)}</span></span>`;
}
return html`<span ${valueRef}
class=object-value-unavailable
title=${i18nString(UIStrings.valueNotAccessibleToTheDebugger)}>${
i18nString(UIStrings.valueUnavailable)}</span>`;
};
const onActivate = (event: MouseEvent|KeyboardEvent): void => {
if (event instanceof KeyboardEvent && !Platform.KeyboardUtilities.isEnterOrSpaceKey(event)) {
return;
}
event.consume(true);
if (input.editable && property.value && !property.value.customPreview() && (property.writable || property.setter)) {
input.startEditing();
}
};
// clang-format off
render(
html`<span class=name-and-value><span
${ref(e => { output.nameElement = e; })}
class=${nameClasses}
title=${input.node.path}>${property.private ?
html`<span class="private-property-hash">${property.name[0]}</span>${
property.name.substring(1)}</span>` : quotedName}</span>${
isInternalEntries ? nothing :
html`<span class='separator'>: </span><devtools-prompt
@commit=${(e: UI.TextPrompt.TextPromptElement.CommitEvent) => input.editingCommitted(e.detail)}
@cancel=${() => input.editingEnded()}
@beforeautocomplete=${onAutoComplete}
@dblclick=${onActivate}
@keydown=${onActivate}
completions=${completionsId}
placeholder=${i18nString(UIStrings.stringIsTooLargeToEdit)}
?editing=${input.editing}>
${input.expanded && isExpandable && property.value ?
html`<span
class="value object-value-${property.value.subtype || property.value.type}"
title=${ifDefined(property.value.description)}>${
property.value.description === 'Object' ? '' :
Platform.StringUtilities.trimMiddle(property.value.description ?? '',
maxRenderableStringLength)}${
property.synthetic ? nothing :
ObjectPropertiesSection.getMemoryIcon(property.value)}</span>` :
value()
}
<datalist id=${completionsId}>${repeat(input.completions, c => html`<option>${c}</option>`)}</datalist>
</devtools-prompt></span>`}</span>`,
target);
// clang-format on
};
export class ObjectPropertyWidget extends UI.Widget.Widget {
#highlightChanges: Highlighting.HighlightChange[] = [];
#property?: ObjectTreeNode;
#nameElement?: Element;
#valueElement?: Element;
#completions: string[] = [];
#editing = false;
readonly #view: ObjectPropertyView;
#expanded = false;
#linkifier: Components.Linkifier.Linkifier|undefined;
#editable = false;
constructor(target?: HTMLElement, view = OBJECT_PROPERTY_DEFAULT_VIEW) {
super(target);
this.#view = view;
}
get property(): ObjectTreeNode|undefined {
return this.#property;
}
set property(property: ObjectTreeNode) {
if (this.#property) {
this.#property.removeEventListener(ObjectTreeNodeBase.Events.VALUE_CHANGED, this.requestUpdate, this);
this.#property.removeEventListener(ObjectTreeNodeBase.Events.CHILDREN_CHANGED, this.requestUpdate, this);
this.#property.removeEventListener(ObjectTreeNodeBase.Events.FILTER_CHANGED, this.requestUpdate, this);
}
this.#property = property;
this.#property.addEventListener(ObjectTreeNodeBase.Events.VALUE_CHANGED, this.requestUpdate, this);
this.#property.addEventListener(ObjectTreeNodeBase.Events.CHILDREN_CHANGED, this.requestUpdate, this);
this.#property.addEventListener(ObjectTreeNodeBase.Events.FILTER_CHANGED, this.requestUpdate, this);
this.requestUpdate();
}
get expanded(): boolean {
return this.#expanded;
}
set expanded(expanded: boolean) {
this.#expanded = expanded;
this.requestUpdate();
}
get linkifier(): Components.Linkifier.Linkifier|undefined {
return this.#linkifier;
}
set linkifier(linkifier: Components.Linkifier.Linkifier|undefined) {
this.#linkifier = linkifier;
this.requestUpdate();
}
get editable(): boolean {
return this.#editable;
}
set editable(val: boolean) {
this.#editable = val;
this.requestUpdate();
}
override performUpdate(): void {
if (!this.#property) {