/* 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",
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",
preventDescriptorModifiers:
true,
});
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._toolbox;
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._toolbox.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-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._toolbox;
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();
}
}
editItem(data) {
const selectedItem =
this.tree.selectedItem;
if (!selectedItem) {
return;
}
const front =
this.getCurrentFront();
front.editItem(data);
}
/**
* 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.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
*/
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();
front.addItem(
this.table.editBookmark, host);
}
/**
* 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;
}