chrome-devtools-frontend
Version: 
Chrome DevTools UI
671 lines (565 loc) • 22.7 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 rulesdir/no-imperative-dom-api */
/*
 * Copyright (C) 2009 280 North Inc. All Rights Reserved.
 *
 * 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 i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import type * as CPUProfile from '../../models/cpu_profile/cpu_profile.js';
import * as IconButton from '../../ui/components/icon_button/icon_button.js';
import * as DataGrid from '../../ui/legacy/components/data_grid/data_grid.js';
import * as UI from '../../ui/legacy/legacy.js';
const UIStrings = {
  /**
   * @description This message is presented as a tooltip when developers investigate the performance
   * of a page. The tooltip alerts developers that some parts of code in execution were not optimized
   * (made to run faster) and that associated timing information must be considered with this in
   * mind. The placeholder text is the reason the code was not optimized.
   * @example {Optimized too many times} PH1
   */
  notOptimizedS: 'Not optimized: {PH1}',
  /**
   * @description Generic text with two placeholders separated by a comma
   * @example {1 613 680} PH1
   * @example {44 %} PH2
   */
  genericTextTwoPlaceholders: '{PH1}, {PH2}',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/profiler/ProfileDataGrid.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export class ProfileDataGridNode extends DataGrid.DataGrid.DataGridNode<unknown> {
  searchMatchedSelfColumn: boolean;
  searchMatchedTotalColumn: boolean;
  searchMatchedFunctionColumn: boolean;
  profileNode: CPUProfile.ProfileTreeModel.ProfileNode;
  tree: ProfileDataGridTree;
  childrenByCallUID: Map<string, ProfileDataGridNode>;
  lastComparator: unknown;
  callUID: string;
  self: number;
  total: number;
  functionName: string;
  readonly deoptReason: string;
  url: Platform.DevToolsPath.UrlString;
  linkElement: Element|null;
  populated: boolean;
  savedSelf?: number;
  savedTotal?: number;
  savedChildren?: Array<DataGrid.DataGrid.DataGridNode<unknown>>;
  constructor(
      profileNode: CPUProfile.ProfileTreeModel.ProfileNode, owningTree: ProfileDataGridTree, hasChildren: boolean) {
    super(null, hasChildren);
    this.searchMatchedSelfColumn = false;
    this.searchMatchedTotalColumn = false;
    this.searchMatchedFunctionColumn = false;
    this.profileNode = profileNode;
    this.tree = owningTree;
    this.childrenByCallUID = new Map();
    this.lastComparator = null;
    this.callUID = profileNode.callUID;
    this.self = profileNode.self;
    this.total = profileNode.total;
    this.functionName = UI.UIUtils.beautifyFunctionName(profileNode.functionName);
    this.deoptReason = profileNode.deoptReason || '';
    this.url = profileNode.url;
    this.linkElement = null;
    this.populated = false;
  }
  static sort<T>(gridNodeGroups: ProfileDataGridNode[][], comparator: (arg0: T, arg1: T) => number, force: boolean):
      void {
    for (let gridNodeGroupIndex = 0; gridNodeGroupIndex < gridNodeGroups.length; ++gridNodeGroupIndex) {
      const gridNodes = gridNodeGroups[gridNodeGroupIndex];
      const count = gridNodes.length;
      for (let index = 0; index < count; ++index) {
        const gridNode = gridNodes[index];
        // If the grid node is collapsed, then don't sort children (save operation for later).
        // If the grid node has the same sorting as previously, then there is no point in sorting it again.
        if (!force && (!gridNode.expanded || gridNode.lastComparator === comparator)) {
          if (gridNode.children.length) {
            gridNode.shouldRefreshChildren = true;
          }
          continue;
        }
        gridNode.lastComparator = comparator;
        const children = gridNode.children;
        const childCount = children.length;
        if (childCount) {
          // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration)
          // @ts-expect-error
          children.sort(comparator);
          for (let childIndex = 0; childIndex < childCount; ++childIndex) {
            children[childIndex].recalculateSiblings(childIndex);
          }
          gridNodeGroups.push((children as ProfileDataGridNode[]));
        }
      }
    }
  }
  static merge(container: ProfileDataGridTree|ProfileDataGridNode, child: ProfileDataGridNode, shouldAbsorb: boolean):
      void {
    container.self += child.self;
    if (!shouldAbsorb) {
      container.total += child.total;
    }
    let children = container.children.slice();
    container.removeChildren();
    let count: number = children.length;
    for (let index = 0; index < count; ++index) {
      if (!shouldAbsorb || children[index] !== child) {
        container.appendChild((children[index] as ProfileDataGridNode));
      }
    }
    children = child.children.slice();
    count = children.length;
    for (let index = 0; index < count; ++index) {
      const orphanedChild = (children[index] as ProfileDataGridNode);
      const existingChild = container.childrenByCallUID.get(orphanedChild.callUID);
      if (existingChild) {
        existingChild.merge((orphanedChild), false);
      } else {
        container.appendChild(orphanedChild);
      }
    }
  }
  static populate(container: ProfileDataGridTree|ProfileDataGridNode): void {
    if (container.populated) {
      return;
    }
    container.populated = true;
    container.populateChildren();
    const currentComparator = container.tree.lastComparator;
    if (currentComparator) {
      container.sort(currentComparator, true);
    }
  }
  override createCell(columnId: string): HTMLElement {
    switch (columnId) {
      case 'self': {
        const cell = this.createValueCell(this.self, this.selfPercent, columnId);
        cell.classList.toggle('highlight', this.searchMatchedSelfColumn);
        return cell;
      }
      case 'total': {
        const cell = this.createValueCell(this.total, this.totalPercent, columnId);
        cell.classList.toggle('highlight', this.searchMatchedTotalColumn);
        return cell;
      }
      case 'function': {
        const cell = this.createTD(columnId);
        cell.classList.toggle('highlight', this.searchMatchedFunctionColumn);
        if (this.deoptReason) {
          cell.classList.add('not-optimized');
          const warningIcon = new IconButton.Icon.Icon();
          warningIcon.name = 'warning-filled';
          warningIcon.classList.add('profile-warn-marker', 'small');
          UI.Tooltip.Tooltip.install(warningIcon, i18nString(UIStrings.notOptimizedS, {PH1: this.deoptReason}));
          cell.appendChild(warningIcon);
        }
        UI.UIUtils.createTextChild(cell, this.functionName);
        if (this.profileNode.scriptId === '0') {
          return cell;
        }
        const urlElement = this.tree.formatter.linkifyNode(this);
        if (!urlElement) {
          return cell;
        }
        (urlElement as HTMLElement).style.maxWidth = '75%';
        cell.appendChild(urlElement);
        this.linkElement = urlElement;
        return cell;
      }
    }
    return super.createCell(columnId);
  }
  createValueCell(value: number, percent: number, columnId: string): HTMLElement {
    const cell = document.createElement('td');
    cell.classList.add('numeric-column');
    const div = cell.createChild('div', 'profile-multiple-values');
    const valueSpan = div.createChild('span');
    const valueText = this.tree.formatter.formatValue(value, this);
    valueSpan.textContent = valueText;
    const percentSpan = div.createChild('span', 'percent-column');
    const percentText = this.tree.formatter.formatPercent(percent, this);
    percentSpan.textContent = percentText;
    const valueAccessibleText = this.tree.formatter.formatValueAccessibleText(value, this);
    this.setCellAccessibleName(
        i18nString(UIStrings.genericTextTwoPlaceholders, {PH1: valueAccessibleText, PH2: percentText}), cell, columnId);
    return cell;
  }
  sort(comparator: (arg0: ProfileDataGridNode, arg1: ProfileDataGridNode) => number, force: boolean): void {
    const sortComparator =
        (comparator as (arg0: DataGrid.DataGrid.DataGridNode<unknown>, arg1: DataGrid.DataGrid.DataGridNode<unknown>) =>
             number);
    return ProfileDataGridNode.sort([[this]], sortComparator, force);
  }
  override insertChild(child: DataGrid.DataGrid.DataGridNode<unknown>, index: number): void {
    const profileDataGridNode = (child as ProfileDataGridNode);
    super.insertChild(profileDataGridNode, index);
    this.childrenByCallUID.set(profileDataGridNode.callUID, (profileDataGridNode));
  }
  override removeChild(profileDataGridNode: DataGrid.DataGrid.DataGridNode<unknown>): void {
    super.removeChild(profileDataGridNode);
    this.childrenByCallUID.delete((profileDataGridNode as ProfileDataGridNode).callUID);
  }
  override removeChildren(): void {
    super.removeChildren();
    this.childrenByCallUID.clear();
  }
  findChild(node: CPUProfile.ProfileTreeModel.ProfileNode): ProfileDataGridNode|null {
    if (!node) {
      return null;
    }
    return this.childrenByCallUID.get(node.callUID) || null;
  }
  get selfPercent(): number {
    return this.self / this.tree.total * 100.0;
  }
  get totalPercent(): number {
    return this.total / this.tree.total * 100.0;
  }
  override populate(): void {
    ProfileDataGridNode.populate(this);
  }
  populateChildren(): void {
    // Not implemented.
  }
  // When focusing and collapsing we modify lots of nodes in the tree.
  // This allows us to restore them all to their original state when we revert.
  save(): void {
    if (this.savedChildren) {
      return;
    }
    this.savedSelf = this.self;
    this.savedTotal = this.total;
    this.savedChildren = this.children.slice();
  }
  /**
   * When focusing and collapsing we modify lots of nodes in the tree.
   * This allows us to restore them all to their original state when we revert.
   */
  restore(): void {
    if (!this.savedChildren) {
      return;
    }
    if (this.savedSelf && this.savedTotal) {
      this.self = this.savedSelf;
      this.total = this.savedTotal;
    }
    this.removeChildren();
    const children = this.savedChildren;
    const count = children.length;
    for (let index = 0; index < count; ++index) {
      (children[index] as ProfileDataGridNode).restore();
      this.appendChild(children[index]);
    }
  }
  merge(child: ProfileDataGridNode, shouldAbsorb: boolean): void {
    ProfileDataGridNode.merge(this, child, shouldAbsorb);
  }
}
export class ProfileDataGridTree implements UI.SearchableView.Searchable {
  tree: this;
  self: number;
  children: ProfileDataGridNode[];
  readonly formatter: Formatter;
  readonly searchableView: UI.SearchableView.SearchableView;
  total: number;
  lastComparator: ((arg0: ProfileDataGridNode, arg1: ProfileDataGridNode) => number)|null;
  childrenByCallUID: Map<string, ProfileDataGridNode>;
  deepSearch: boolean;
  populated: boolean;
  searchResults!: Array<{
    profileNode: ProfileDataGridNode,
  }>;
  savedTotal?: number;
  savedChildren?: ProfileDataGridNode[]|null;
  searchResultIndex = -1;
  constructor(formatter: Formatter, searchableView: UI.SearchableView.SearchableView, total: number) {
    this.tree = this;
    this.self = 0;
    this.children = [];
    this.formatter = formatter;
    this.searchableView = searchableView;
    this.total = total;
    this.lastComparator = null;
    this.childrenByCallUID = new Map();
    this.deepSearch = true;
    this.populated = false;
  }
  static propertyComparator(property: string, isAscending: boolean):
      (arg0: Record<string, unknown>, arg1: Record<string, unknown>) => number {
    let comparator = propertyComparators[(isAscending ? 1 : 0)][property];
    if (!comparator) {
      if (isAscending) {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        comparator = function(lhs: Record<string, any>, rhs: Record<string, any>): number {
          if (lhs[property] < rhs[property]) {
            return -1;
          }
          if (lhs[property] > rhs[property]) {
            return 1;
          }
          return 0;
        };
      } else {
        comparator = function(
            // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration)
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            lhs: Record<string, any>, rhs: Record<string, any>): number {
          if (lhs[property] > rhs[property]) {
            return -1;
          }
          if (lhs[property] < rhs[property]) {
            return 1;
          }
          return 0;
        };
      }
      propertyComparators[(isAscending ? 1 : 0)][property] = comparator;
    }
    return comparator as (arg0: Record<string, unknown>, arg1: Record<string, unknown>) => number;
  }
  get expanded(): boolean {
    return true;
  }
  appendChild(child: ProfileDataGridNode): void {
    this.insertChild(child, this.children.length);
  }
  focus(_profileDataGridNode: ProfileDataGridNode): void {
  }
  exclude(_profileDataGridNode: ProfileDataGridNode): void {
  }
  insertChild(child: ProfileDataGridNode, index: number): void {
    const childToInsert = (child);
    this.children.splice(index, 0, childToInsert);
    this.childrenByCallUID.set(childToInsert.callUID, child);
  }
  removeChildren(): void {
    this.children = [];
    this.childrenByCallUID.clear();
  }
  populateChildren(): void {
    // Not implemented.
  }
  findChild(node: CPUProfile.ProfileTreeModel.ProfileNode): ProfileDataGridNode|null {
    if (!node) {
      return null;
    }
    return this.childrenByCallUID.get(node.callUID) || null;
  }
  sort<T>(comparator: (arg0: T, arg1: T) => number, force: boolean): void {
    // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration)
    // @ts-expect-error
    return ProfileDataGridNode.sort([[this]], comparator, force);
  }
  save(): void {
    if (this.savedChildren) {
      return;
    }
    this.savedTotal = this.total;
    this.savedChildren = this.children.slice();
  }
  restore(): void {
    if (!this.savedChildren) {
      return;
    }
    this.children = this.savedChildren;
    if (this.savedTotal) {
      this.total = this.savedTotal;
    }
    const children = this.children;
    const count = children.length;
    for (let index = 0; index < count; ++index) {
      (children[index]).restore();
    }
    this.savedChildren = null;
  }
  matchFunction(searchConfig: UI.SearchableView.SearchConfig): ((arg0: ProfileDataGridNode) => boolean)|null {
    const query = searchConfig.query.trim();
    if (!query.length) {
      return null;
    }
    const greaterThan = (query.startsWith('>'));
    const lessThan = (query.startsWith('<'));
    let equalTo: true|boolean = (query.startsWith('=') || ((greaterThan || lessThan) && query.indexOf('=') === 1));
    const percentUnits = (query.endsWith('%'));
    const millisecondsUnits = (query.length > 2 && query.endsWith('ms'));
    const secondsUnits = (!millisecondsUnits && query.endsWith('s'));
    let queryNumber = parseFloat(query);
    if (greaterThan || lessThan || equalTo) {
      if (equalTo && (greaterThan || lessThan)) {
        queryNumber = parseFloat(query.substring(2));
      } else {
        queryNumber = parseFloat(query.substring(1));
      }
    }
    const queryNumberMilliseconds = (secondsUnits ? (queryNumber * 1000) : queryNumber);
    // Make equalTo implicitly true if it wasn't specified there is no other operator.
    if (!isNaN(queryNumber) && !(greaterThan || lessThan)) {
      equalTo = true;
    }
    const matcher = Platform.StringUtilities.createPlainTextSearchRegex(query, 'i');
    function matchesQuery(profileDataGridNode: ProfileDataGridNode): boolean {
      profileDataGridNode.searchMatchedSelfColumn = false;
      profileDataGridNode.searchMatchedTotalColumn = false;
      profileDataGridNode.searchMatchedFunctionColumn = false;
      if (percentUnits) {
        if (lessThan) {
          if (profileDataGridNode.selfPercent < queryNumber) {
            profileDataGridNode.searchMatchedSelfColumn = true;
          }
          if (profileDataGridNode.totalPercent < queryNumber) {
            profileDataGridNode.searchMatchedTotalColumn = true;
          }
        } else if (greaterThan) {
          if (profileDataGridNode.selfPercent > queryNumber) {
            profileDataGridNode.searchMatchedSelfColumn = true;
          }
          if (profileDataGridNode.totalPercent > queryNumber) {
            profileDataGridNode.searchMatchedTotalColumn = true;
          }
        }
        if (equalTo) {
          if (profileDataGridNode.selfPercent === queryNumber) {
            profileDataGridNode.searchMatchedSelfColumn = true;
          }
          if (profileDataGridNode.totalPercent === queryNumber) {
            profileDataGridNode.searchMatchedTotalColumn = true;
          }
        }
      } else if (millisecondsUnits || secondsUnits) {
        if (lessThan) {
          if (profileDataGridNode.self < queryNumberMilliseconds) {
            profileDataGridNode.searchMatchedSelfColumn = true;
          }
          if (profileDataGridNode.total < queryNumberMilliseconds) {
            profileDataGridNode.searchMatchedTotalColumn = true;
          }
        } else if (greaterThan) {
          if (profileDataGridNode.self > queryNumberMilliseconds) {
            profileDataGridNode.searchMatchedSelfColumn = true;
          }
          if (profileDataGridNode.total > queryNumberMilliseconds) {
            profileDataGridNode.searchMatchedTotalColumn = true;
          }
        }
        if (equalTo) {
          if (profileDataGridNode.self === queryNumberMilliseconds) {
            profileDataGridNode.searchMatchedSelfColumn = true;
          }
          if (profileDataGridNode.total === queryNumberMilliseconds) {
            profileDataGridNode.searchMatchedTotalColumn = true;
          }
        }
      }
      if (profileDataGridNode.functionName.match(matcher) ||
          (profileDataGridNode.url && profileDataGridNode.url.match(matcher))) {
        profileDataGridNode.searchMatchedFunctionColumn = true;
      }
      if (profileDataGridNode.searchMatchedSelfColumn || profileDataGridNode.searchMatchedTotalColumn ||
          profileDataGridNode.searchMatchedFunctionColumn) {
        profileDataGridNode.refresh();
        return true;
      }
      return false;
    }
    return matchesQuery;
  }
  performSearch(searchConfig: UI.SearchableView.SearchConfig, _shouldJump: boolean, jumpBackwards?: boolean): void {
    this.onSearchCanceled();
    const matchesQuery = this.matchFunction(searchConfig);
    if (!matchesQuery) {
      return;
    }
    this.searchResults = [];
    const deepSearch = this.deepSearch;
    let current: DataGrid.DataGrid.DataGridNode<unknown>|null;
    for (current = this.children[0]; current; current = current.traverseNextNode(!deepSearch, null, !deepSearch)) {
      const item = (current as ProfileDataGridNode | null);
      if (!item) {
        break;
      }
      if (matchesQuery(item)) {
        this.searchResults.push({profileNode: item});
      }
    }
    this.searchResultIndex = jumpBackwards ? 0 : this.searchResults.length - 1;
    this.searchableView.updateSearchMatchesCount(this.searchResults.length);
    this.searchableView.updateCurrentMatchIndex(this.searchResultIndex);
  }
  onSearchCanceled(): void {
    if (this.searchResults) {
      for (let i = 0; i < this.searchResults.length; ++i) {
        const profileNode = this.searchResults[i].profileNode;
        profileNode.searchMatchedSelfColumn = false;
        profileNode.searchMatchedTotalColumn = false;
        profileNode.searchMatchedFunctionColumn = false;
        profileNode.refresh();
      }
    }
    this.searchResults = [];
    this.searchResultIndex = -1;
  }
  jumpToNextSearchResult(): void {
    if (!this.searchResults?.length) {
      return;
    }
    this.searchResultIndex = (this.searchResultIndex + 1) % this.searchResults.length;
    this.jumpToSearchResult(this.searchResultIndex);
  }
  jumpToPreviousSearchResult(): void {
    if (!this.searchResults?.length) {
      return;
    }
    this.searchResultIndex = (this.searchResultIndex - 1 + this.searchResults.length) % this.searchResults.length;
    this.jumpToSearchResult(this.searchResultIndex);
  }
  supportsCaseSensitiveSearch(): boolean {
    return true;
  }
  supportsWholeWordSearch(): boolean {
    return false;
  }
  supportsRegexSearch(): boolean {
    return false;
  }
  jumpToSearchResult(index: number): void {
    const searchResult = this.searchResults[index];
    if (!searchResult) {
      return;
    }
    const profileNode = searchResult.profileNode;
    profileNode.revealAndSelect();
    this.searchableView.updateCurrentMatchIndex(index);
  }
}
const propertyComparators: Array<Record<string, unknown>> = [{}, {}];
export interface Formatter {
  formatValue(value: number, node: ProfileDataGridNode): string;
  formatValueAccessibleText(value: number, node: ProfileDataGridNode): string;
  formatPercent(value: number, node: ProfileDataGridNode): string;
  linkifyNode(node: ProfileDataGridNode): Element|null;
}