/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
const { ELLIPSIS } = require("resource://devtools/shared/l10n.js");
const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js");
const {
  parseItemValue,
} = require("resource://devtools/shared/storage/utils.js");
const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");
const {
  getUnicodeHostname,
} = require("resource://devtools/client/shared/unicode-url.js");
const getStorageTypeURL = require("resource://devtools/client/storage/utils/doc-utils.js");

// GUID to be used as a separator in compound keys. This must match the same
// constant in devtools/server/actors/resources/storage/index.js,
// devtools/client/storage/test/head.js and
// devtools/server/tests/browser/head.js
const SEPARATOR_GUID = "{9d414cc5-8319-0a04-0586-c0a6ae01670a}";

loader.lazyRequireGetter(
  this,
  "TreeWidget",
  "resource://devtools/client/shared/widgets/TreeWidget.js",
  true
);
loader.lazyRequireGetter(
  this,
  "TableWidget",
  "resource://devtools/client/shared/widgets/TableWidget.js",
  true
);
loader.lazyRequireGetter(
  this,
  "debounce",
  "resource://devtools/shared/debounce.js",
  true
);
loader.lazyGetter(this, "standardSessionString", () => {
  const l10n = new Localization(["devtools/client/storage.ftl"], true);
  return l10n.formatValueSync("storage-expires-session");
});

const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  VariablesView: "resource://devtools/client/storage/VariablesView.sys.mjs",
});

const REASON = {
  NEW_ROW: "new-row",
  NEXT_50_ITEMS: "next-50-items",
  POPULATE: "populate",
  UPDATE: "update",
};

// How long we wait to debounce resize events
const LAZY_RESIZE_INTERVAL_MS = 200;

// Maximum length of item name to show in context menu label - will be
// trimmed with ellipsis if it's longer.
const ITEM_NAME_MAX_LENGTH = 32;

const HEADERS_L10N_IDS = {
  Cache: {
    status: "storage-table-headers-cache-status",
  },
  cookies: {
    creationTime: "storage-table-headers-cookies-creation-time",
    updateTime: "storage-table-headers-cookies-update-time",
    expires: "storage-table-headers-cookies-expires",
    lastAccessed: "storage-table-headers-cookies-last-accessed",
    name: "storage-table-headers-cookies-name",
    size: "storage-table-headers-cookies-size",
    value: "storage-table-headers-cookies-value",
  },
  extensionStorage: {
    area: "storage-table-headers-extension-storage-area",
  },
};

// We only localize certain table headers. The headers that we do not localize
// along with their label are stored in this dictionary for easy reference.
const HEADERS_NON_L10N_STRINGS = {
  Cache: {
    url: "URL",
  },
  cookies: {
    host: "Domain",
    hostOnly: "HostOnly",
    isHttpOnly: "HttpOnly",
    isSecure: "Secure",
    partitionKey: "Partition Key",
    path: "Path",
    sameSite: "SameSite",
    uniqueKey: "Unique key",
  },
  extensionStorage: {
    name: "Key",
    value: "Value",
  },
  indexedDB: {
    autoIncrement: "Auto Increment",
    db: "Database Name",
    indexes: "Indexes",
    keyPath: "Key Path",
    name: "Key",
    objectStore: "Object Store Name",
    objectStores: "Object Stores",
    origin: "Origin",
    storage: "Storage",
    uniqueKey: "Unique key",
    value: "Value",
    version: "Version",
  },
  localStorage: {
    name: "Key",
    value: "Value",
  },
  sessionStorage: {
    name: "Key",
    value: "Value",
  },
};

/**
 * StorageUI is controls and builds the UI of the Storage Inspector.
 *
 * @param {Window} panelWin
 *        Window of the toolbox panel to populate UI in.
 * @param {object} commands
 *        The commands object with all interfaces defined from devtools/shared/commands/
 */
class StorageUI {
  constructor(panelWin, toolbox, commands) {
    EventEmitter.decorate(this);
    this._window = panelWin;
    this._panelDoc = panelWin.document;
    this._toolbox = toolbox;
    this._commands = commands;
    this.sidebarToggledOpen = null;
    this.shouldLoadMoreItems = true;

    const treeNode = this._panelDoc.getElementById("storage-tree");
    this.tree = new TreeWidget(treeNode, {
      defaultType: "dir",
      contextMenuId: "storage-tree-popup",
    });
    this.onHostSelect = this.onHostSelect.bind(this);
    this.tree.on("select", this.onHostSelect);

    const tableNode = this._panelDoc.getElementById("storage-table");
    this.table = new TableWidget(tableNode, {
      emptyText: "storage-table-empty-text",
      highlightUpdated: true,
      cellContextMenuId: "storage-table-popup",
      l10n: this._panelDoc.l10n,
    });

    this.updateObjectSidebar = this.updateObjectSidebar.bind(this);
    this.table.on(TableWidget.EVENTS.ROW_SELECTED, this.updateObjectSidebar);

    this.handleScrollEnd = this.loadMoreItems.bind(this);
    this.table.on(TableWidget.EVENTS.SCROLL_END, this.handleScrollEnd);

    this.editItem = this.editItem.bind(this);
    this.table.on(TableWidget.EVENTS.CELL_EDIT, this.editItem);

    this.sidebar = this._panelDoc.getElementById("storage-sidebar");

    // Set suggested sizes for the xul:splitter's, so that the sidebar doesn't take too much space
    // in horizontal mode (width) and vertical (height).
    this.sidebar.style.width = "300px";
    this.sidebar.style.height = "300px";

    this.view = new lazy.VariablesView(this.sidebar.firstChild, {
      lazyEmpty: true,
      // ms
      lazyEmptyDelay: 10,
      searchEnabled: true,
      contextMenuId: "variable-view-popup",
    });

    this.filterItems = this.filterItems.bind(this);
    this.onPaneToggleButtonClicked = this.onPaneToggleButtonClicked.bind(this);
    this.setupToolbar();

    this.handleKeypress = this.handleKeypress.bind(this);
    this._panelDoc.addEventListener("keypress", this.handleKeypress);

    this.onTreePopupShowing = this.onTreePopupShowing.bind(this);
    this._treePopup = this._panelDoc.getElementById("storage-tree-popup");
    this._treePopup.addEventListener("popupshowing", this.onTreePopupShowing);

    this.onTablePopupShowing = this.onTablePopupShowing.bind(this);
    this._tablePopup = this._panelDoc.getElementById("storage-table-popup");
    this._tablePopup.addEventListener("popupshowing", this.onTablePopupShowing);

    this.onVariableViewPopupShowing =
      this.onVariableViewPopupShowing.bind(this);
    this._variableViewPopup = this._panelDoc.getElementById(
      "variable-view-popup"
    );
    this._variableViewPopup.addEventListener(
      "popupshowing",
      this.onVariableViewPopupShowing
    );

    this.onRefreshTable = this.onRefreshTable.bind(this);
    this.onAddItem = this.onAddItem.bind(this);
    this.onCopyItem = this.onCopyItem.bind(this);
    this.onPanelWindowResize = debounce(
      this.#onLazyPanelResize,
      LAZY_RESIZE_INTERVAL_MS,
      this
    );
    this.onRemoveItem = this.onRemoveItem.bind(this);
    this.onRemoveAllFrom = this.onRemoveAllFrom.bind(this);
    this.onRemoveAll = this.onRemoveAll.bind(this);
    this.onRemoveAllSessionCookies = this.onRemoveAllSessionCookies.bind(this);
    this.onRemoveTreeItem = this.onRemoveTreeItem.bind(this);

    this._refreshButton = this._panelDoc.getElementById("refresh-button");
    this._refreshButton.addEventListener("click", this.onRefreshTable);

    this._addButton = this._panelDoc.getElementById("add-button");
    this._addButton.addEventListener("click", this.onAddItem);

    this._window.addEventListener("resize", this.onPanelWindowResize, true);

    this._variableViewPopupCopy = this._panelDoc.getElementById(
      "variable-view-popup-copy"
    );
    this._variableViewPopupCopy.addEventListener("command", this.onCopyItem);

    this._tablePopupAddItem = this._panelDoc.getElementById(
      "storage-table-popup-add"
    );
    this._tablePopupAddItem.addEventListener("command", this.onAddItem);

    this._tablePopupDelete = this._panelDoc.getElementById(
      "storage-table-popup-delete"
    );
    this._tablePopupDelete.addEventListener("command", this.onRemoveItem);

    this._tablePopupDeleteAllFrom = this._panelDoc.getElementById(
      "storage-table-popup-delete-all-from"
    );
    this._tablePopupDeleteAllFrom.addEventListener(
      "command",
      this.onRemoveAllFrom
    );

    this._tablePopupDeleteAll = this._panelDoc.getElementById(
      "storage-table-popup-delete-all"
    );
    this._tablePopupDeleteAll.addEventListener("command", this.onRemoveAll);

    this._tablePopupDeleteAllSessionCookies = this._panelDoc.getElementById(
      "storage-table-popup-delete-all-session-cookies"
    );
    this._tablePopupDeleteAllSessionCookies.addEventListener(
      "command",
      this.onRemoveAllSessionCookies
    );

    this._treePopupDeleteAll = this._panelDoc.getElementById(
      "storage-tree-popup-delete-all"
    );
    this._treePopupDeleteAll.addEventListener("command", this.onRemoveAll);

    this._treePopupDeleteAllSessionCookies = this._panelDoc.getElementById(
      "storage-tree-popup-delete-all-session-cookies"
    );
    this._treePopupDeleteAllSessionCookies.addEventListener(
      "command",
      this.onRemoveAllSessionCookies
    );

    this._treePopupDelete = this._panelDoc.getElementById(
      "storage-tree-popup-delete"
    );
    this._treePopupDelete.addEventListener("command", this.onRemoveTreeItem);
  }

  get currentTarget() {
    return this._commands.targetCommand.targetFront;
  }

  async init() {
    // This is a distionary of arrays, keyed by storage key
    // - Keys are storage keys, available on each storage resource, via ${resource.resourceKey}
    //   and are typically "Cache", "cookies", "indexedDB", "localStorage", ...
    // - Values are arrays of storage fronts. This isn't the deprecated global storage front (target.getFront(storage), only used by legacy listener),
    //   but rather the storage specific front, i.e. a storage resource. Storage resources are fronts.
    this.storageResources = {};

    await this._initL10NStringsMap();

    // This can only be done after l10n strings were retrieved as we're using "storage-filter-key"
    const shortcuts = new KeyShortcuts({
      window: this._panelDoc.defaultView,
    });
    const key = this._l10nStrings.get("storage-filter-key");
    shortcuts.on(key, event => {
      event.preventDefault();
      this.searchBox.focus();
    });

    this._onTargetAvailable = this._onTargetAvailable.bind(this);
    this._onTargetDestroyed = this._onTargetDestroyed.bind(this);
    await this._commands.targetCommand.watchTargets({
      types: [this._commands.targetCommand.TYPES.FRAME],
      onAvailable: this._onTargetAvailable,
      onDestroyed: this._onTargetDestroyed,
    });

    this._onResourceListAvailable = this._onResourceListAvailable.bind(this);

    const { resourceCommand } = this._commands;

    this._listenedResourceTypes = [
      // The first item in this list will be the first selected storage item
      // Tests assume Cookie -- moving cookie will break tests
      resourceCommand.TYPES.COOKIE,
      resourceCommand.TYPES.CACHE_STORAGE,
      resourceCommand.TYPES.INDEXED_DB,
      resourceCommand.TYPES.LOCAL_STORAGE,
      resourceCommand.TYPES.SESSION_STORAGE,
    ];
    // EXTENSION_STORAGE is only relevant when debugging web extensions
    if (this._commands.descriptorFront.isWebExtensionDescriptor) {
      this._listenedResourceTypes.push(resourceCommand.TYPES.EXTENSION_STORAGE);
    }
    await this._commands.resourceCommand.watchResources(
      this._listenedResourceTypes,
      {
        onAvailable: this._onResourceListAvailable,
      }
    );
  }

  async _initL10NStringsMap() {
    const ids = [
      "storage-filter-key",
      "storage-table-headers-cookies-name",
      "storage-table-headers-cookies-value",
      "storage-table-headers-cookies-expires",
      "storage-table-headers-cookies-size",
      "storage-table-headers-cookies-last-accessed",
      "storage-table-headers-cookies-creation-time",
      "storage-table-headers-cookies-update-time",
      "storage-table-headers-cache-status",
      "storage-table-headers-extension-storage-area",
      "storage-tree-labels-cookies",
      "storage-tree-labels-local-storage",
      "storage-tree-labels-session-storage",
      "storage-tree-labels-indexed-db",
      "storage-tree-labels-cache",
      "storage-tree-labels-extension-storage",
      "storage-expires-session",
    ];
    const results = await this._panelDoc.l10n.formatValues(
      ids.map(s => ({ id: s }))
    );

    this._l10nStrings = new Map(ids.map((id, i) => [id, results[i]]));
  }

  async _onResourceListAvailable(resources) {
    for (const resource of resources) {
      if (resource.isDestroyed()) {
        continue;
      }
      const { resourceKey } = resource;

      // NOTE: We might be getting more than 1 resource per storage type when
      //       we have remote frames in content process resources, so we need
      //       an array to store these.
      if (!this.storageResources[resourceKey]) {
        this.storageResources[resourceKey] = [];
      }
      this.storageResources[resourceKey].push(resource);

      resource.on(
        "single-store-update",
        this._onStoreUpdate.bind(this, resource)
      );
      resource.on(
        "single-store-cleared",
        this._onStoreCleared.bind(this, resource)
      );
    }

    try {
      await this.populateStorageTree();
    } catch (e) {
      if (!this._toolbox || this._toolbox._destroyer) {
        // The toolbox is in the process of being destroyed... in this case throwing here
        // is expected and normal so let's ignore the error.
        return;
      }

      // The toolbox is open so the error is unexpected and real so let's log it.
      console.error(e);
    }
  }

  // We only need to listen to target destruction, but TargetCommand.watchTarget
  // requires a target available function...
  async _onTargetAvailable() {}

  _onTargetDestroyed({ targetFront }) {
    // Remove all storages related to this target
    for (const type in this.storageResources) {
      this.storageResources[type] = this.storageResources[type].filter(
        storage => {
          // Note that the storage front may already be destroyed,
          // and have a null targetFront attribute. So also remove all already
          // destroyed fronts.
          return !storage.isDestroyed() && storage.targetFront != targetFront;
        }
      );
    }

    // Only support top level target and navigation to new processes.
    // i.e. ignore additional targets created for remote <iframes>
    if (!targetFront.isTopLevel) {
      return;
    }

    this.storageResources = {};
    this.table.clear();
    this.hideSidebar();
    this.tree.clear();
  }

  set animationsEnabled(value) {
    this._panelDoc.documentElement.classList.toggle("no-animate", !value);
  }

  destroy() {
    if (this._destroyed) {
      return;
    }
    this._destroyed = true;

    const { resourceCommand } = this._commands;
    resourceCommand.unwatchResources(this._listenedResourceTypes, {
      onAvailable: this._onResourceListAvailable,
    });

    this.table.off(TableWidget.EVENTS.ROW_SELECTED, this.updateObjectSidebar);
    this.table.off(TableWidget.EVENTS.SCROLL_END, this.loadMoreItems);
    this.table.off(TableWidget.EVENTS.CELL_EDIT, this.editItem);
    this.table.destroy();

    this._panelDoc.removeEventListener("keypress", this.handleKeypress);
    this.searchBox.removeEventListener("input", this.filterItems);
    this.searchBox = null;

    this.sidebarToggleBtn.removeEventListener(
      "click",
      this.onPaneToggleButtonClicked
    );
    this.sidebarToggleBtn = null;

    this._window.removeEventListener("resize", this.#onLazyPanelResize, true);

    this._treePopup.removeEventListener(
      "popupshowing",
      this.onTreePopupShowing
    );
    this._refreshButton.removeEventListener("click", this.onRefreshTable);
    this._addButton.removeEventListener("click", this.onAddItem);
    this._tablePopupAddItem.removeEventListener("command", this.onAddItem);
    this._treePopupDeleteAll.removeEventListener("command", this.onRemoveAll);
    this._treePopupDeleteAllSessionCookies.removeEventListener(
      "command",
      this.onRemoveAllSessionCookies
    );
    this._treePopupDelete.removeEventListener("command", this.onRemoveTreeItem);

    this._tablePopup.removeEventListener(
      "popupshowing",
      this.onTablePopupShowing
    );
    this._tablePopupDelete.removeEventListener("command", this.onRemoveItem);
    this._tablePopupDeleteAllFrom.removeEventListener(
      "command",
      this.onRemoveAllFrom
    );
    this._tablePopupDeleteAll.removeEventListener("command", this.onRemoveAll);
    this._tablePopupDeleteAllSessionCookies.removeEventListener(
      "command",
      this.onRemoveAllSessionCookies
    );
  }

  setupToolbar() {
    this.searchBox = this._panelDoc.getElementById("storage-searchbox");
    this.searchBox.addEventListener("input", this.filterItems);

    // Setup the sidebar toggle button.
    this.sidebarToggleBtn = this._panelDoc.querySelector(".sidebar-toggle");
    this.updateSidebarToggleButton();

    this.sidebarToggleBtn.addEventListener(
      "click",
      this.onPaneToggleButtonClicked
    );
  }

  onPaneToggleButtonClicked() {
    if (this.sidebar.hidden && this.table.selectedRow) {
      this.sidebar.hidden = false;
      this.sidebarToggledOpen = true;
      this.updateSidebarToggleButton();
    } else {
      this.sidebarToggledOpen = false;
      this.hideSidebar();
    }
  }

  updateSidebarToggleButton() {
    let dataL10nId;
    this.sidebarToggleBtn.hidden = !this.table.hasSelectedRow;

    if (this.sidebar.hidden) {
      this.sidebarToggleBtn.classList.add("pane-collapsed");
      dataL10nId = "storage-expand-pane";
    } else {
      this.sidebarToggleBtn.classList.remove("pane-collapsed");
      dataL10nId = "storage-collapse-pane";
    }

    this._panelDoc.l10n.setAttributes(this.sidebarToggleBtn, dataL10nId);
  }

  /**
   * Hide the object viewer sidebar
   */
  hideSidebar() {
    this.sidebar.hidden = true;
    this.updateSidebarToggleButton();
  }

  getCurrentFront() {
    const { datatype, host } = this.table;
    return this._getStorage(datatype, host);
  }

  _getStorage(type, host) {
    const storageType = this.storageResources[type];
    return storageType.find(x => host in x.hosts);
  }

  /**
   *  Make column fields editable
   *
   *  @param {Array} editableFields
   *         An array of keys of columns to be made editable
   */
  makeFieldsEditable(editableFields) {
    if (editableFields && editableFields.length) {
      this.table.makeFieldsEditable(editableFields);
    } else if (this.table._editableFieldsEngine) {
      this.table._editableFieldsEngine.destroy();
    }
  }

  async editItem(data, cellEditAbortController) {
    const selectedItem = this.tree.selectedItem;
    if (!selectedItem) {
      return;
    }
    const front = this.getCurrentFront();

    const result = await front.editItem(data);
    // At the moment, only editing cookies can return an error
    if (front.typeName === "cookies" && result?.errorString) {
      const notificationBox = this._toolbox.getNotificationBox();
      const message = await this._panelDoc.l10n.formatValue(
        "storage-cookie-edit-error",
        { errorString: result.errorString }
      );

      notificationBox.appendNotification(
        message,
        "storage-cookie-edit-error",
        null,
        notificationBox.PRIORITY_WARNING_LOW
      );

      // Revert value in table
      cellEditAbortController.abort();
    }
  }

  /**
   * Removes the given item from the storage table. Reselects the next item in
   * the table and repopulates the sidebar with that item's data if the item
   * being removed was selected.
   */
  async removeItemFromTable(name) {
    if (this.table.isSelected(name) && this.table.items.size > 1) {
      if (this.table.selectedIndex == 0) {
        this.table.selectNextRow();
      } else {
        this.table.selectPreviousRow();
      }
    }

    this.table.remove(name);
    await this.updateObjectSidebar();
  }

  /**
   * Event handler for "stores-cleared" event coming from the storage actor.
   *
   * @param {object}
   *        An object containing which hosts/paths are cleared from a
   *        storage
   */
  _onStoreCleared(resource, { clearedHostsOrPaths }) {
    const { resourceKey } = resource;
    function* enumPaths() {
      if (Array.isArray(clearedHostsOrPaths)) {
        // Handle the legacy response with array of hosts
        for (const host of clearedHostsOrPaths) {
          yield [host];
        }
      } else {
        // Handle the new format that supports clearing sub-stores in a host
        for (const host in clearedHostsOrPaths) {
          const paths = clearedHostsOrPaths[host];

          if (!paths.length) {
            yield [host];
          } else {
            for (let path of paths) {
              try {
                path = JSON.parse(path);
                yield [host, ...path];
              } catch (ex) {
                // ignore
              }
            }
          }
        }
      }
    }

    for (const path of enumPaths()) {
      // Find if the path is selected (there is max one) and clear it
      if (this.tree.isSelected([resourceKey, ...path])) {
        this.table.clear();
        this.hideSidebar();

        // Reset itemOffset to 0 so that items added after local storate is
        // cleared will be shown
        this.itemOffset = 0;

        this.emit("store-objects-cleared");
        break;
      }
    }
  }

  /**
   * Event handler for "stores-update" event coming from the storage actor.
   *
   * @param {object} argument0
   *        An object containing the details of the added, changed and deleted
   *        storage objects.
   *        Each of these 3 objects are of the following format:
   *        {
   *          <store_type1>: {
   *            <host1>: [<store_names1>, <store_name2>...],
   *            <host2>: [<store_names34>...], ...
   *          },
   *          <store_type2>: {
   *            <host1>: [<store_names1>, <store_name2>...],
   *            <host2>: [<store_names34>...], ...
   *          }, ...
   *        }
   *        Where store_type1 and store_type2 is one of cookies, indexedDB,
   *        sessionStorage and localStorage; host1, host2 are the host in which
   *        this change happened; and [<store_namesX] is an array of the names
   *        of the changed store objects. This array is empty for deleted object
   *        if the host was completely removed.
   */
  async _onStoreUpdate(resource, update) {
    const { changed, added, deleted } = update;
    if (added) {
      await this.handleAddedItems(added);
    }

    if (changed) {
      await this.handleChangedItems(changed);
    }

    // We are dealing with batches of changes here. Deleted **MUST** come last in case it
    // is in the same batch as added and changed events e.g.
    //   - An item is changed then deleted in the same batch: deleted then changed will
    //     display an item that has been deleted.
    //   - An item is added then deleted in the same batch: deleted then added will
    //     display an item that has been deleted.
    if (deleted) {
      await this.handleDeletedItems(deleted);
    }

    if (added || deleted || changed) {
      this.emit("store-objects-edit");
    }
  }

  /**
   * If the panel is resized we need to check if we should load the next batch of
   * storage items.
   */
  async #onLazyPanelResize() {
    // We can be called on a closed window or destroyed toolbox because of the
    // deferred task.
    if (this._window.closed || this._destroyed || this.table.hasScrollbar) {
      return;
    }

    await this.loadMoreItems();
    this.emit("storage-resize");
  }

  /**
   * Get a string for a column name automatically choosing whether or not the
   * string should be localized.
   *
   * @param {string} type
   *        The storage type.
   * @param {string} name
   *        The field name that may need to be localized.
   */
  _getColumnName(type, name) {
    // If the ID exists in HEADERS_NON_L10N_STRINGS then we do not translate it
    const columnName = HEADERS_NON_L10N_STRINGS[type]?.[name];
    if (columnName) {
      return columnName;
    }

    // otherwise we get it from the L10N Map (populated during init)
    const l10nId = HEADERS_L10N_IDS[type]?.[name];
    if (l10nId && this._l10nStrings.has(l10nId)) {
      return this._l10nStrings.get(l10nId);
    }

    // If the string isn't localized, we will just use the field name.
    return name;
  }

  /**
   * Handle added items received by onEdit
   *
   * @param {object} See onEdit docs
   */
  async handleAddedItems(added) {
    for (const type in added) {
      for (const host in added[type]) {
        const label = this.getReadableLabelFromHostname(host);
        this.tree.add([type, { id: host, label, type: "url" }]);
        for (let name of added[type][host]) {
          try {
            name = JSON.parse(name);
            if (name.length == 3) {
              name.splice(2, 1);
            }
            this.tree.add([type, host, ...name]);
            if (!this.tree.selectedItem) {
              this.tree.selectedItem = [type, host, name[0], name[1]];
              await this.fetchStorageObjects(
                type,
                host,
                [JSON.stringify(name)],
                REASON.NEW_ROW
              );
            }
          } catch (ex) {
            // Do nothing
          }
        }

        if (this.tree.isSelected([type, host])) {
          await this.fetchStorageObjects(
            type,
            host,
            added[type][host],
            REASON.NEW_ROW
          );
        }
      }
    }
  }

  /**
   * Handle deleted items received by onEdit
   *
   * @param {object} See onEdit docs
   */
  async handleDeletedItems(deleted) {
    for (const type in deleted) {
      for (const host in deleted[type]) {
        if (!deleted[type][host].length) {
          // This means that the whole host is deleted, thus the item should
          // be removed from the storage tree
          if (this.tree.isSelected([type, host])) {
            this.table.clear();
            this.hideSidebar();
            this.tree.selectPreviousItem();
          }

          this.tree.remove([type, host]);
        } else {
          for (const name of deleted[type][host]) {
            try {
              if (["indexedDB", "Cache"].includes(type)) {
                // For indexedDB and Cache, the key is being parsed because
                // these storages are represented as a tree and the key
                // used to notify their changes is not a simple string.
                const names = JSON.parse(name);
                // Is a whole cache, database or objectstore deleted?
                // Then remove it from the tree.
                if (names.length < 3) {
                  if (this.tree.isSelected([type, host, ...names])) {
                    this.table.clear();
                    this.hideSidebar();
                    this.tree.selectPreviousItem();
                  }
                  this.tree.remove([type, host, ...names]);
                }

                // Remove the item from table if currently displayed.
                if (names.length) {
                  const tableItemName = names.pop();
                  if (this.tree.isSelected([type, host, ...names])) {
                    await this.removeItemFromTable(tableItemName);
                  }
                }
              } else if (this.tree.isSelected([type, host])) {
                // For all the other storage types with a simple string key,
                // remove the item from the table by name without any parsing.
                await this.removeItemFromTable(name);
              }
            } catch (ex) {
              if (this.tree.isSelected([type, host])) {
                await this.removeItemFromTable(name);
              }
            }
          }
        }
      }
    }
  }

  /**
   * Handle changed items received by onEdit
   *
   * @param {object} See onEdit docs
   */
  async handleChangedItems(changed) {
    const selectedItem = this.tree.selectedItem;
    if (!selectedItem) {
      return;
    }

    const [type, host, db, objectStore] = selectedItem;
    if (!changed[type] || !changed[type][host] || !changed[type][host].length) {
      return;
    }
    try {
      const toUpdate = [];
      for (const name of changed[type][host]) {
        if (["indexedDB", "Cache"].includes(type)) {
          // For indexedDB and Cache, the key is being parsed because
          // these storage are represented as a tree and the key
          // used to notify their changes is not a simple string.
          const names = JSON.parse(name);
          if (names[0] == db && names[1] == objectStore && names[2]) {
            toUpdate.push(name);
          }
        } else {
          // For all the other storage types with a simple string key,
          // update the item from the table by name without any parsing.
          toUpdate.push(name);
        }
      }
      await this.fetchStorageObjects(type, host, toUpdate, REASON.UPDATE);
    } catch (ex) {
      await this.fetchStorageObjects(
        type,
        host,
        changed[type][host],
        REASON.UPDATE
      );
    }
  }

  /**
   * Fetches the storage objects from the storage actor and populates the
   * storage table with the returned data.
   *
   * @param {string} type
   *        The type of storage. Ex. "cookies"
   * @param {string} host
   *        Hostname
   * @param {Array} names
   *        Names of particular store objects. Empty if all are requested
   * @param {Constant} reason
   *        See REASON constant at top of file.
   */
  async fetchStorageObjects(type, host, names, reason) {
    const fetchOpts =
      reason === REASON.NEXT_50_ITEMS ? { offset: this.itemOffset } : {};
    fetchOpts.sessionString = standardSessionString;
    const storage = this._getStorage(type, host);
    this.sidebarToggledOpen = null;

    if (
      reason !== REASON.NEXT_50_ITEMS &&
      reason !== REASON.UPDATE &&
      reason !== REASON.NEW_ROW &&
      reason !== REASON.POPULATE
    ) {
      throw new Error("Invalid reason specified");
    }

    try {
      if (
        reason === REASON.POPULATE ||
        (reason === REASON.NEW_ROW && this.table.items.size === 0)
      ) {
        let subType = null;
        // The indexedDB type could have sub-type data to fetch.
        // If having names specified, then it means
        // we are fetching details of specific database or of object store.
        if (type === "indexedDB" && names) {
          const [dbName, objectStoreName] = JSON.parse(names[0]);
          if (dbName) {
            subType = "database";
          }
          if (objectStoreName) {
            subType = "object store";
          }
        }

        await this.resetColumns(type, host, subType);
      }

      const { data, total } = await storage.getStoreObjects(
        host,
        names,
        fetchOpts
      );
      if (data.length) {
        await this.populateTable(data, reason, total);
      } else if (reason === REASON.POPULATE) {
        await this.clearHeaders();
      }
      this.updateToolbar();
      this.emit("store-objects-updated");
    } catch (ex) {
      console.error(ex);
    }
  }

  supportsAddItem(type, host) {
    const storage = this._getStorage(type, host);
    return storage?.traits.supportsAddItem || false;
  }

  supportsRemoveItem(type, host) {
    const storage = this._getStorage(type, host);
    return storage?.traits.supportsRemoveItem || false;
  }

  supportsRemoveAll(type, host) {
    const storage = this._getStorage(type, host);
    return storage?.traits.supportsRemoveAll || false;
  }

  supportsRemoveAllSessionCookies(type, host) {
    const storage = this._getStorage(type, host);
    return storage?.traits.supportsRemoveAllSessionCookies || false;
  }

  /**
   * Updates the toolbar hiding and showing buttons as appropriate.
   */
  updateToolbar() {
    const item = this.tree.selectedItem;
    if (!item) {
      return;
    }

    const [type, host] = item;

    // Add is only supported if the selected item has a host.
    this._addButton.hidden = !host || !this.supportsAddItem(type, host);
  }

  /**
   * Populates the storage tree which displays the list of storages present for
   * the page.
   */
  async populateStorageTree() {
    const populateTreeFromResource = (type, resource) => {
      for (const host in resource.hosts) {
        const label = this.getReadableLabelFromHostname(host);
        this.tree.add([type, { id: host, label, type: "url" }]);
        for (const name of resource.hosts[host]) {
          try {
            const names = JSON.parse(name);
            this.tree.add([type, host, ...names]);
            if (!this.tree.selectedItem) {
              this.tree.selectedItem = [type, host, names[0], names[1]];
            }
          } catch (ex) {
            // Do Nothing
          }
        }
        if (!this.tree.selectedItem) {
          this.tree.selectedItem = [type, host];
        }
      }
    };

    // When can we expect the "store-objects-updated" event?
    //   -> TreeWidget setter `selectedItem` emits a "select" event
    //   -> on tree "select" event, this module calls `onHostSelect`
    //   -> finally `onHostSelect` calls `fetchStorageObjects`, which will emit
    //      "store-objects-updated" at the end of the method.
    // So if the selection changed, we can wait for "store-objects-updated",
    // which is emitted at the end of `fetchStorageObjects`.
    const onStoresObjectsUpdated = this.once("store-objects-updated");

    // Save the initially selected item to check if tree.selected was updated,
    // see comment above.
    const initialSelectedItem = this.tree.selectedItem;

    for (const [type, resources] of Object.entries(this.storageResources)) {
      let typeLabel = type;
      try {
        typeLabel = this.getStorageTypeLabel(type);
      } catch (e) {
        console.error("Unable to localize tree label type:" + type);
      }

      this.tree.add([{ id: type, label: typeLabel, type: "store" }]);

      // storageResources values are arrays, with storage resources.
      // we may have many storage resources per type if we get remote iframes.
      for (const resource of resources) {
        populateTreeFromResource(type, resource);
      }
    }

    if (initialSelectedItem !== this.tree.selectedItem) {
      await onStoresObjectsUpdated;
    }
  }

  getStorageTypeLabel(type) {
    let dataL10nId;

    switch (type) {
      case "cookies":
        dataL10nId = "storage-tree-labels-cookies";
        break;
      case "localStorage":
        dataL10nId = "storage-tree-labels-local-storage";
        break;
      case "sessionStorage":
        dataL10nId = "storage-tree-labels-session-storage";
        break;
      case "indexedDB":
        dataL10nId = "storage-tree-labels-indexed-db";
        break;
      case "Cache":
        dataL10nId = "storage-tree-labels-cache";
        break;
      case "extensionStorage":
        dataL10nId = "storage-tree-labels-extension-storage";
        break;
      default:
        throw new Error("Unknown storage type");
    }

    return this._l10nStrings.get(dataL10nId);
  }

  /**
   * Populates the selected entry from the table in the sidebar for a more
   * detailed view.
   */
  /* eslint-disable-next-line */
  async updateObjectSidebar() {
    const item = this.table.selectedRow;
    let value;

    // Get the string value (async action) and the update the UI synchronously.
    if ((item?.name || item?.name === "") && item?.valueActor) {
      value = await item.valueActor.string();
    }

    // Bail if the selectedRow is no longer selected, the item doesn't exist or the state
    // changed in another way during the above yield.
    if (
      this.table.items.size === 0 ||
      !item ||
      !this.table.selectedRow ||
      item.uniqueKey !== this.table.selectedRow.uniqueKey
    ) {
      this.hideSidebar();
      return;
    }

    // Start updating the UI. Everything is sync beyond this point.
    if (this.sidebarToggledOpen === null || this.sidebarToggledOpen === true) {
      this.sidebar.hidden = false;
    }

    this.updateSidebarToggleButton();
    this.view.empty();
    const mainScope = this.view.addScope("storage-data");
    mainScope.expanded = true;

    if (value) {
      const itemVar = mainScope.addItem(item.name + "", {}, { relaxed: true });

      // The main area where the value will be displayed
      itemVar.setGrip(value);

      // May be the item value is a json or a key value pair itself
      const obj = parseItemValue(value);
      if (typeof obj === "object") {
        this.populateSidebar(item.name, obj);
      }

      // By default the item name and value are shown. If this is the only
      // information available, then nothing else is to be displayed.
      const itemProps = Object.keys(item);
      if (itemProps.length > 3) {
        // Display any other information other than the item name and value
        // which may be available.
        const rawObject = Object.create(null);
        const otherProps = itemProps.filter(
          e => !["name", "value", "valueActor"].includes(e)
        );
        for (const prop of otherProps) {
          const column = this.table.columns.get(prop);
          if (column?.private) {
            continue;
          }

          const fieldName = this._getColumnName(this.table.datatype, prop);
          rawObject[fieldName] = item[prop];
        }
        itemVar.populate(rawObject, { sorted: true });
        itemVar.twisty = true;
        itemVar.expanded = true;
      }
    } else {
      // Case when displaying IndexedDB db/object store properties.
      for (const key in item) {
        const column = this.table.columns.get(key);
        if (column?.private) {
          continue;
        }

        mainScope.addItem(key, {}, true).setGrip(item[key]);
        const obj = parseItemValue(item[key]);
        if (typeof obj === "object") {
          this.populateSidebar(item.name, obj);
        }
      }
    }

    this.emit("sidebar-updated");
  }

  /**
   * Gets a readable label from the hostname. If the hostname is a Punycode
   * domain(I.e. an ASCII domain name representing a Unicode domain name), then
   * this function decodes it to the readable Unicode domain name, and label
   * the Unicode domain name toggether with the original domian name, and then
   * return the label; if the hostname isn't a Punycode domain(I.e. it isn't
   * encoded and is readable on its own), then this function simply returns the
   * original hostname.
   *
   * @param {string} host
   *        The string representing a host, e.g, example.com, example.com:8000
   */
  getReadableLabelFromHostname(host) {
    try {
      const { hostname } = new URL(host);
      const unicodeHostname = getUnicodeHostname(hostname);
      if (hostname !== unicodeHostname) {
        // If the hostname is a Punycode domain representing a Unicode domain,
        // we decode it to the Unicode domain name, and then label the Unicode
        // domain name together with the original domain name.
        return host.replace(hostname, unicodeHostname) + " [ " + host + " ]";
      }
    } catch (_) {
      // Skip decoding for a host which doesn't include a domain name, simply
      // consider them to be readable.
    }
    return host;
  }

  /**
   * Populates the sidebar with a parsed object.
   *
   * @param {object} obj - Either a json or a key-value separated object or a
   * key separated array
   */
  populateSidebar(name, obj) {
    const jsonObject = Object.create(null);
    const view = this.view;
    jsonObject[name] = obj;
    const valueScope =
      view.getScopeAtIndex(1) || view.addScope("storage-parsed-value");
    valueScope.expanded = true;
    const jsonVar = valueScope.addItem("", Object.create(null), {
      relaxed: true,
    });
    jsonVar.expanded = true;
    jsonVar.twisty = true;
    jsonVar.populate(jsonObject, { expanded: true });
  }

  /**
   * Select handler for the storage tree. Fetches details of the selected item
   * from the storage details and populates the storage tree.
   *
   * @param {Array} item
   *        An array of ids which represent the location of the selected item in
   *        the storage tree
   */
  async onHostSelect(item) {
    if (!item) {
      return;
    }

    this.table.clear();
    this.hideSidebar();
    this.searchBox.value = "";

    const [type, host] = item;
    this.table.host = host;
    this.table.datatype = type;

    this.updateToolbar();

    let names = null;
    if (!host) {
      let storageTypeHintL10nId = "";
      switch (type) {
        case "Cache":
          storageTypeHintL10nId = "storage-table-type-cache-hint";
          break;
        case "cookies":
          storageTypeHintL10nId = "storage-table-type-cookies-hint";
          break;
        case "extensionStorage":
          storageTypeHintL10nId = "storage-table-type-extensionstorage-hint";
          break;
        case "localStorage":
          storageTypeHintL10nId = "storage-table-type-localstorage-hint";
          break;
        case "indexedDB":
          storageTypeHintL10nId = "storage-table-type-indexeddb-hint";
          break;
        case "sessionStorage":
          storageTypeHintL10nId = "storage-table-type-sessionstorage-hint";
          break;
      }
      this.table.setPlaceholder(
        storageTypeHintL10nId,
        getStorageTypeURL(this.table.datatype)
      );

      // If selected item has no host then reset table headers
      await this.clearHeaders();
      return;
    }
    if (item.length > 2) {
      names = [JSON.stringify(item.slice(2))];
    }

    this.itemOffset = 0;
    await this.fetchStorageObjects(type, host, names, REASON.POPULATE);
  }

  /**
   * Clear the column headers in the storage table
   */
  async clearHeaders() {
    this.table.setColumns({}, null, {}, {});
  }

  /**
   * Resets the column headers in the storage table with the pased object `data`
   *
   * @param {string} type
   *        The type of storage corresponding to the after-reset columns in the
   *        table.
   * @param {string} host
   *        The host name corresponding to the table after reset.
   *
   * @param {string} [subType]
   *        The sub type under the given type.
   */
  async resetColumns(type, host, subtype) {
    this.table.host = host;
    this.table.datatype = type;

    let uniqueKey = null;
    const columns = {};
    const editableFields = [];
    const hiddenFields = [];
    const privateFields = [];
    const fields = await this.getCurrentFront().getFields(subtype);

    fields.forEach(f => {
      if (!uniqueKey) {
        this.table.uniqueId = uniqueKey = f.name;
      }

      if (f.editable) {
        editableFields.push(f.name);
      }

      if (f.hidden) {
        hiddenFields.push(f.name);
      }

      if (f.private) {
        privateFields.push(f.name);
      }

      const columnName = this._getColumnName(type, f.name);
      if (columnName) {
        columns[f.name] = columnName;
      } else if (!f.private) {
        // Private fields are only displayed when running tests so there is no
        // need to log an error if they are not localized.
        columns[f.name] = f.name;
        console.error(
          `No string defined in HEADERS_NON_L10N_STRINGS for '${type}.${f.name}'`
        );
      }
    });

    this.table.setColumns(columns, null, hiddenFields, privateFields);
    this.hideSidebar();

    this.makeFieldsEditable(editableFields);
  }

  /**
   * Populates or updates the rows in the storage table.
   *
   * @param {Array[object]} data
   *        Array of objects to be populated in the storage table
   * @param {Constant} reason
   *        See REASON constant at top of file.
   * @param {number} totalAvailable
   *        The total number of items available in the current storage type.
   */
  async populateTable(data, reason, totalAvailable) {
    for (const item of data) {
      if (item.value) {
        item.valueActor = item.value;
        item.value = item.value.initial || "";
      }
      if (item.expires != null) {
        item.expires = item.expires
          ? new Date(item.expires).toUTCString()
          : this._l10nStrings.get("storage-expires-session");
      }
      if (item.creationTime != null) {
        item.creationTime = new Date(item.creationTime).toUTCString();
      }
      if (item.updateTime != null) {
        item.updateTime = new Date(item.updateTime).toUTCString();
      }
      if (item.lastAccessed != null) {
        item.lastAccessed = new Date(item.lastAccessed).toUTCString();
      }

      switch (reason) {
        case REASON.POPULATE:
        case REASON.NEXT_50_ITEMS:
          // Update without flashing the row.
          this.table.push(item, true);
          break;
        case REASON.NEW_ROW:
          // Update and flash the row.
          this.table.push(item, false);
          break;
        case REASON.UPDATE:
          this.table.update(item);
          if (item == this.table.selectedRow && !this.sidebar.hidden) {
            await this.updateObjectSidebar();
          }
          break;
      }

      this.shouldLoadMoreItems = true;
    }

    if (
      (reason === REASON.POPULATE || reason === REASON.NEXT_50_ITEMS) &&
      this.table.items.size < totalAvailable &&
      !this.table.hasScrollbar
    ) {
      await this.loadMoreItems();
    }
  }

  /**
   * Handles keypress event on the body table to close the sidebar when open
   *
   * @param {DOMEvent} event
   *        The event passed by the keypress event.
   */
  handleKeypress(event) {
    if (event.keyCode == KeyCodes.DOM_VK_ESCAPE) {
      if (!this.sidebar.hidden) {
        this.hideSidebar();
        this.sidebarToggledOpen = false;
        // Stop Propagation to prevent opening up of split console
        event.stopPropagation();
        event.preventDefault();
      }
    } else if (
      event.keyCode == KeyCodes.DOM_VK_BACK_SPACE ||
      event.keyCode == KeyCodes.DOM_VK_DELETE
    ) {
      if (this.table.selectedRow && event.target.localName != "input") {
        this.onRemoveItem(event);
        event.stopPropagation();
        event.preventDefault();
      }
    }
  }

  /**
   * Handles filtering the table
   */
  filterItems() {
    const value = this.searchBox.value;
    this.table.filterItems(value, ["valueActor"]);
    this._panelDoc.documentElement.classList.toggle("filtering", !!value);
  }

  /**
   * Load the next batch of 50 items
   */
  async loadMoreItems() {
    if (
      !this.shouldLoadMoreItems ||
      this._toolbox.currentToolId !== "storage" ||
      !this.tree.selectedItem
    ) {
      return;
    }
    this.shouldLoadMoreItems = false;
    this.itemOffset += 50;

    const item = this.tree.selectedItem;
    const [type, host] = item;
    let names = null;
    if (item.length > 2) {
      names = [JSON.stringify(item.slice(2))];
    }
    await this.fetchStorageObjects(type, host, names, REASON.NEXT_50_ITEMS);
  }

  /**
   * Fires before a cell context menu with the "Add" or "Delete" action is
   * shown. If the currently selected storage object doesn't support adding or
   * removing items, prevent showing the menu.
   */
  onTablePopupShowing(event) {
    const selectedItem = this.tree.selectedItem;
    const [type, host] = selectedItem;

    // IndexedDB only supports removing items from object stores (level 4 of the tree)
    if (
      (!this.supportsAddItem(type, host) &&
        !this.supportsRemoveItem(type, host)) ||
      (type === "indexedDB" && selectedItem.length !== 4)
    ) {
      event.preventDefault();
      return;
    }

    const rowId = this.table.contextMenuRowId;
    const data = this.table.items.get(rowId);

    if (this.supportsRemoveItem(type, host)) {
      const name = data[this.table.uniqueId];
      const separatorRegex = new RegExp(SEPARATOR_GUID, "g");
      const label = addEllipsis((name + "").replace(separatorRegex, "-"));

      this._panelDoc.l10n.setArgs(this._tablePopupDelete, { itemName: label });
      this._tablePopupDelete.hidden = false;
    } else {
      this._tablePopupDelete.hidden = true;
    }

    this._tablePopupAddItem.hidden = !this.supportsAddItem(type, host);

    let showDeleteAllSessionCookies = false;
    if (this.supportsRemoveAllSessionCookies(type, host)) {
      if (selectedItem.length === 2) {
        showDeleteAllSessionCookies = true;
      }
    }

    this._tablePopupDeleteAllSessionCookies.hidden =
      !showDeleteAllSessionCookies;

    if (type === "cookies") {
      const hostString = addEllipsis(data.host);

      this._panelDoc.l10n.setArgs(this._tablePopupDeleteAllFrom, {
        host: hostString,
      });
      this._tablePopupDeleteAllFrom.hidden = false;
    } else {
      this._tablePopupDeleteAllFrom.hidden = true;
    }
  }

  onTreePopupShowing(event) {
    let showMenu = false;
    const selectedItem = this.tree.selectedItem;

    if (selectedItem) {
      const [type, host] = selectedItem;

      // The delete all (aka clear) action is displayed for IndexedDB object stores
      // (level 4 of tree), for Cache objects (level 3) and for the whole host (level 2)
      // for other storage types (cookies, localStorage, ...).
      let showDeleteAll = false;
      if (this.supportsRemoveAll(type, host)) {
        let level;
        if (type == "indexedDB") {
          level = 4;
        } else if (type == "Cache") {
          level = 3;
        } else {
          level = 2;
        }

        if (selectedItem.length == level) {
          showDeleteAll = true;
        }
      }

      this._treePopupDeleteAll.hidden = !showDeleteAll;

      // The delete all session cookies action is displayed for cookie object stores
      // (level 2 of tree)
      let showDeleteAllSessionCookies = false;
      if (this.supportsRemoveAllSessionCookies(type, host)) {
        if (type === "cookies" && selectedItem.length === 2) {
          showDeleteAllSessionCookies = true;
        }
      }

      this._treePopupDeleteAllSessionCookies.hidden =
        !showDeleteAllSessionCookies;

      // The delete action is displayed for:
      // - IndexedDB databases (level 3 of the tree)
      // - Cache objects (level 3 of the tree)
      const showDelete =
        (type == "indexedDB" || type == "Cache") && selectedItem.length == 3;
      this._treePopupDelete.hidden = !showDelete;
      if (showDelete) {
        const itemName = addEllipsis(selectedItem[selectedItem.length - 1]);
        this._panelDoc.l10n.setArgs(this._treePopupDelete, { itemName });
      }

      showMenu = showDeleteAll || showDelete;
    }

    if (!showMenu) {
      event.preventDefault();
    }
  }

  onVariableViewPopupShowing() {
    const item = this.view.getFocusedItem();
    this._variableViewPopupCopy.setAttribute("disabled", !item);
  }

  /**
   * Handles refreshing the selected storage
   */
  async onRefreshTable() {
    await this.onHostSelect(this.tree.selectedItem);
  }

  /**
   * Handles adding an item from the storage
   */
  async onAddItem() {
    const selectedItem = this.tree.selectedItem;
    if (!selectedItem) {
      return;
    }

    const front = this.getCurrentFront();
    const [, host] = selectedItem;

    // Prepare to scroll into view.
    this.table.scrollIntoViewOnUpdate = true;
    this.table.editBookmark = createGUID();
    const result = await front.addItem(this.table.editBookmark, host);

    // At the moment, only adding cookies can (theorically) return an error (although in
    // practice, since we set the default properties of the cookie ourselves, this shouldn't
    // happen).
    if (front.typeName === "cookies" && result?.errorString) {
      const notificationBox = this._toolbox.getNotificationBox();
      const message = await this._panelDoc.l10n.formatValue(
        "storage-cookie-create-error",
        { errorString: result.errorString }
      );

      notificationBox.appendNotification(
        message,
        "storage-cookie-create-error",
        null,
        notificationBox.PRIORITY_WARNING_LOW
      );
    }
  }

  /**
   * Handles copy an item from the storage
   */
  onCopyItem() {
    this.view._copyItem();
  }

  /**
   * Handles removing an item from the storage
   *
   * @param {DOMEvent} event
   *        The event passed by the command or keypress event.
   */
  onRemoveItem(event) {
    const [, host, ...path] = this.tree.selectedItem;
    const front = this.getCurrentFront();
    const uniqueId = this.table.uniqueId;
    const rowId =
      event.type == "command"
        ? this.table.contextMenuRowId
        : this.table.selectedRow[uniqueId];
    const data = this.table.items.get(rowId);

    let name = data[uniqueId];
    if (path.length) {
      name = JSON.stringify([...path, name]);
    }
    front.removeItem(host, name);

    return false;
  }

  /**
   * Handles removing all items from the storage
   */
  onRemoveAll() {
    // Cannot use this.currentActor() if the handler is called from the
    // tree context menu: it returns correct value only after the table
    // data from server are successfully fetched (and that's async).
    const [, host, ...path] = this.tree.selectedItem;
    const front = this.getCurrentFront();
    const name = path.length ? JSON.stringify(path) : undefined;
    front.removeAll(host, name);
  }

  /**
   * Handles removing all session cookies from the storage
   */
  onRemoveAllSessionCookies() {
    // Cannot use this.currentActor() if the handler is called from the
    // tree context menu: it returns the correct value only after the
    // table data from server is successfully fetched (and that's async).
    const [, host, ...path] = this.tree.selectedItem;
    const front = this.getCurrentFront();
    const name = path.length ? JSON.stringify(path) : undefined;
    front.removeAllSessionCookies(host, name);
  }

  /**
   * Handles removing all cookies with exactly the same domain as the
   * cookie in the selected row.
   */
  onRemoveAllFrom() {
    const [, host] = this.tree.selectedItem;
    const front = this.getCurrentFront();
    const rowId = this.table.contextMenuRowId;
    const data = this.table.items.get(rowId);

    front.removeAll(host, data.host);
  }

  onRemoveTreeItem() {
    const [type, host, ...path] = this.tree.selectedItem;

    if (type == "indexedDB" && path.length == 1) {
      this.removeDatabase(host, path[0]);
    } else if (type == "Cache" && path.length == 1) {
      this.removeCache(host, path[0]);
    }
  }

  async removeDatabase(host, dbName) {
    const front = this.getCurrentFront();

    try {
      const result = await front.removeDatabase(host, dbName);
      if (result.blocked) {
        const notificationBox = this._toolbox.getNotificationBox();
        const message = await this._panelDoc.l10n.formatValue(
          "storage-idb-delete-blocked",
          { dbName }
        );

        notificationBox.appendNotification(
          message,
          "storage-idb-delete-blocked",
          null,
          notificationBox.PRIORITY_WARNING_LOW
        );
      }
    } catch (error) {
      const notificationBox = this._toolbox.getNotificationBox();
      const message = await this._panelDoc.l10n.formatValue(
        "storage-idb-delete-error",
        { dbName }
      );
      notificationBox.appendNotification(
        message,
        "storage-idb-delete-error",
        null,
        notificationBox.PRIORITY_CRITICAL_LOW
      );
    }
  }

  removeCache(host, cacheName) {
    const front = this.getCurrentFront();

    front.removeItem(host, JSON.stringify([cacheName]));
  }
}

exports.StorageUI = StorageUI;

// Helper Functions

function createGUID() {
  return "{cccccccc-cccc-4ccc-yccc-cccccccccccc}".replace(/[cy]/g, c => {
    const r = (Math.random() * 16) | 0;
    const v = c == "c" ? r : (r & 0x3) | 0x8;
    return v.toString(16);
  });
}

function addEllipsis(name) {
  if (name.length > ITEM_NAME_MAX_LENGTH) {
    if (/^https?:/.test(name)) {
      // For URLs, add ellipsis in the middle
      const halfLen = ITEM_NAME_MAX_LENGTH / 2;
      return name.slice(0, halfLen) + ELLIPSIS + name.slice(-halfLen);
    }

    // For other strings, add ellipsis at the end
    return name.substr(0, ITEM_NAME_MAX_LENGTH) + ELLIPSIS;
  }

  return name;
}
