chromium/tools/binary_size/libsupersize/viewer/static/metadata-tree-ui.js

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

'use strict';

/**
 * @fileoverview
 * UI classes and helpers for viewing metadata as tree and/or table.
 */

/**
 * @typedef {Object} MetadataItem
 * @property {string} name - Item name.
 * @property {number|string} value - Metadata value.
 * @property {number|string|undefined} beforeValue - Optional "before" metadata
 *   value.
 */

/**
 * @typedef {Object} MetadataTreeNode
 * @property {string|undefined} name - The full name of the node, and is shown
 *     in the UI.
 * @property {!Array<!MetadataTreeNode>|undefined} children - Child nodes.
 *     Non-existent or null indicates this is a leaf node.
 * @property {!Array<!MetadataItem>|undefined} items - For leaf node only, a
 *     list of named metadata values.
 * @property {string|undefined} iconKey - Override value for
 *     getMetadataIconTemplate() to retrieve icon.
 */

/**
 * States and helpers for the Metadata Tree.
 */
class MetadataTreeModel {
  constructor() {
    /**
     * Cached metadata used to create |rootNode|.
     * @public {?Object}
     */
    this.metadata = null;

    /**
     * Root node of the metadata tree, can be regenerated on UI change.
     * @public {?MetadataTreeNode}
     */
    this.rootNode = null;
  }

  /**
   * Renders primitive |value| to string, and an array of primitive values to
   * primitive values separated by '\n'.
   * @param {?Object} value
   * @return {string}
   * @private
   */
  renderSimpleValue(value) {
    if (value === null)
      return 'null';
    const t = typeof value;
    if (t === 'number')
      return formatNumber(value);
    if (t !== 'object')
      return value.toString();
    if (Array.isArray(value)) {
      if (value.every((v) => v === null || typeof v !== 'object'))
        return value.join('\n');
    }
    return '[Object]';
  }

  /**
   * Creates MetadataTreeNode populated with commonly used fields.
   * @param {string} name
   * @param {?Array<!MetadataTreeNode>} children
   * @return {!MetadataTreeNode}
   * @private
   */
  makeDataNode(name, children) {
    const node = /** @type {!MetadataTreeNode} */ ({name});
    if (children)
      node.children = children;
    return node;
  }

  /**
   * Jointly visits two key-value objects to yield MetadataItems, with values
   * rendered to strings.
   * @param {boolean} diffMode
   * @param {!Object} obj
   * @param {!Object} beforeObj
   * @private @generator
   */
  * makeItems(diffMode, obj, beforeObj) {
    const keys = uniquifyIterToString(
        joinIter(Object.keys(obj), Object.keys(beforeObj)));
    for (const key of keys) {
      const item = /** @type {!MetadataItem} */ ({name: key});
      if (diffMode)
        item.beforeValue = this.renderSimpleValue(beforeObj[key] ?? '');
      item.value = this.renderSimpleValue(obj[key] ?? '');
      yield item;
    }
  }

  /**
   * Converts a container array to a Map from a distinct name to each container.
   * @param {!Array<!Object>} containers
   * @return {!Map<!Object>}
   * @private
   */
  containersToMap(containers) {
    const ret = new Map();
    for (const c of containers) {
      let name = c.name;
      while (ret.has(name))  // Ensures distinct name.
        name += '!';
      ret.set(name, c);
    }
    return ret;
  }

  /**
   * Jointly visits two container arrays to yield names and container pairs with
   * null placeholders.
   * @param {!Array<!Object>} containers
   * @param {!Array<!Object>} beforeContainers
   * @private @generator
   */
  * visitContainers(containers, beforeContainers) {
    const containerMap = this.containersToMap(containers);
    const beforeContainerMap = this.containersToMap(beforeContainers);
    const names = uniquifyIterToString(
        joinIter(containerMap.keys(), beforeContainerMap.keys()));
    for (const name of names) {
      yield {
        name,
        container: containerMap.get(name) ?? null,
        beforeContainer: beforeContainerMap.get(name) ?? null
      };
    }
  }

  /**
   * Converts arbitrary |metadata| to a consistent tree form, and stores the
   * result into |rootNode|.
   * @param {?Object} metadata Source metadata, must be non-null on first call.
   *     Subsequently, null means to use cached copy from previous call.
   * @public
   */
  extractAndStoreRoot(metadata) {
    const EMPTY_OBJ = {};
    const diffMode = state.getDiffMode();
    if (metadata)
      this.metadata = metadata;

    const containers = getOrMakeContainers(this.metadata.size_file);
    const beforeContainers =
        getOrMakeContainers(this.metadata.before_size_file);

    const rootNode = this.makeDataNode('Metadata', []);
    rootNode.iconKey = 'root';

    if (this.metadata.size_file?.build_config) {
      const configNode = this.makeDataNode('', null);
      const buildConfig = this.metadata.size_file?.build_config ?? EMPTY_OBJ;
      const beforeBuildConfig =
          this.metadata.before_size_file?.build_config ?? EMPTY_OBJ;
      configNode.items =
          [...this.makeItems(diffMode, buildConfig, beforeBuildConfig)];
      rootNode.children.push(configNode);
    }

    for (const {name, container, beforeContainer} of this.visitContainers(
             containers, beforeContainers)) {
      const subMetadata = container?.metadata ?? EMPTY_OBJ;
      const beforeSubMetadata = beforeContainer?.metadata ?? EMPTY_OBJ;
      const tableNode = this.makeDataNode('', null);
      tableNode.items =
          [...this.makeItems(diffMode, subMetadata, beforeSubMetadata)];
      const outerNode = this.makeDataNode(name, [tableNode]);
      rootNode.children.push(outerNode);
    }

    this.rootNode = rootNode;
  }

  /**
   * Decides whether a MetadataTreeNode is a leaf node.
   * @param {!MetadataTreeNode} dataNode
   * @return {Boolean}
   * @public
   */
  isLeaf(dataNode) {
    return !dataNode.children;
  }
}

/**
 * Class to manage UI to display metadata as a trees with tables as leaves.
 * @extends {TreeUi<MetadataTreeNode>}
 */
class MetadataTreeUi extends TreeUi {
  /** @param {!MetadataTreeModel} model */
  constructor(model) {
    super(g_el.ulMetadataTree);

    /** @private @const {!MetadataTreeModel} */
    this.model = model;

    /** @private @const {function(!KeyboardEvent): *} */
    this.boundHandleKeyDown = this.handleKeyDown.bind(this);
  }

  /**
   * @param {!Array<!MetadataItem>} items
   * @param {!DocumentFragment} fragment
   * @private
   */
  populateTable(items, fragment) {
    const table = fragment.querySelector('table');
    const diffMode = state.getDiffMode();
    for (const item of items) {
      const tr = document.createElement('tr');
      tr.appendChild(dom.textElement('td', item.name, ''));
      const s2 = item.value.toString();
      const td = dom.textElement('td', s2, '');
      if (diffMode) {
        const s1 = item.beforeValue.toString();
        tr.appendChild(dom.textElement('td', s1, ''));
        td.classList.toggle('metadata-changed', s1 !== s2);
      }
      tr.appendChild(td);
      table.appendChild(tr);
    }
  }

  /** @override @protected */
  makeGroupOrLeafFragment(nodeData) {
    const isLeaf = this.model.isLeaf(nodeData);
    // Use different template depending on whether node is group or leaf.
    const tmpl =
        isLeaf ? g_el.tmplMetadataTreeLeaf : g_el.tmplMetadataTreeGroup;
    const fragment = document.importNode(tmpl.content, true);
    const listItemElt = fragment.firstElementChild;
    const nodeElt =
        /** @type {HTMLAnchorElement} */ (listItemElt.firstElementChild);

    // Set the symbol name and hover text.
    if (nodeData.name) {
      const spanSymbolName = /** @type {HTMLSpanElement} */ (
          fragment.querySelector('.symbol-name'));
      spanSymbolName.textContent = nodeData.name;
      spanSymbolName.title = nodeData.name;
    }

    if (nodeData.items)
      this.populateTable(nodeData.items, fragment);

    // Insert type dependent SVG icon at the start of |nodeElt|.
    if (!isLeaf) {
      const icon = getMetadataIconTemplate(nodeData.iconKey ?? 'group');
      nodeElt.insertBefore(icon, nodeElt.firstElementChild);
    }
    return {fragment, isLeaf};
  }

  /** @override @protected */
  async getGroupChildrenData(link) {
    const data = this.uiNodeToData.get(link);
    return data.children;
  }

  /**
   * @param {!KeyboardEvent} event
   * @protected
   */
  handleKeyDown(event) {
    if (event.altKey || event.ctrlKey || event.metaKey)
      return;

    /** @type {!TreeNodeElement} */
    const nodeElt = /** @type {!TreeNodeElement} */ (event.target);
    /** @type {number} Index of this element in the node list */
    const focusIndex = Array.prototype.indexOf.call(this.liveNodeList, nodeElt);

    this.handleKeyNavigationCommon(event, nodeElt, focusIndex);
  }

  /** @override @public */
  init() {
    super.init();

    g_el.ulMetadataTree.addEventListener('keydown', this.boundHandleKeyDown);
  }
}