UNPKG

chrome-devtools-frontend

Version:
748 lines (677 loc) • 26.7 kB
// Copyright 2012 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import '../../ui/components/report_view/report_view.js'; import '../../ui/legacy/legacy.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as SDK from '../../core/sdk/sdk.js'; import * as Buttons from '../../ui/components/buttons/buttons.js'; import * as DataGrid from '../../ui/legacy/components/data_grid/data_grid.js'; import * as ObjectUI from '../../ui/legacy/components/object_ui/object_ui.js'; import * as UI from '../../ui/legacy/legacy.js'; import {Directives, html, type LitTemplate, nothing, render} from '../../ui/lit/lit.js'; import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; import * as ApplicationComponents from './components/components.js'; import type { Database, DatabaseId, Entry, Index, IndexedDBModel, ObjectStore, ObjectStoreMetadata} from './IndexedDBModel.js'; import indexedDBViewsStyles from './indexedDBViews.css.js'; type IDBKeyValue = number|string|Date|IDBKeyValue[]; const UIStrings = { /** * @description Text in Indexed DBViews of the Application panel */ version: 'Version', /** * @description Text in Indexed DBViews of the Application panel */ objectStores: 'Object stores', /** * @description Text of button in Indexed DBViews of the Application panel */ deleteDatabase: 'Delete database', /** * @description Text of button in Indexed DBViews of the Application panel */ refreshDatabase: 'Refresh database', /** * @description Text in Application panel IndexedDB delete confirmation dialog * @example {msb} PH1 */ confirmDeleteDatabase: 'Delete "{PH1}" database?', /** * @description Explanation text in Application panel IndexedDB delete confirmation dialog */ databaseWillBeRemoved: 'The selected database and contained data will be removed.', /** * @description Title of the confirmation dialog in the IndexedDB tab of the Application panel * that the user is about to clear an object store and this cannot be undone. * @example {table1} PH1 */ confirmClearObjectStore: 'Clear "{PH1}" object store?', /** * @description Description in the confirmation dialog in the IndexedDB tab of the Application * panel that the user is about to clear an object store and this cannot be undone. */ objectStoreWillBeCleared: 'The data contained in the selected object store will be removed.', /** * @description Text in Indexed DBViews of the Application panel */ idb: 'IDB', /** * @description Text to refresh the page */ refresh: 'Refresh', /** * @description Tooltip text that appears when hovering over the delete button in the Indexed DBViews of the Application panel */ deleteSelected: 'Delete selected', /** * @description Tooltip text that appears when hovering over the clear button in the Indexed DBViews of the Application panel */ clearObjectStore: 'Clear object store', /** * @description Text in Indexed DBViews of the Application panel */ dataMayBeStale: 'Data may be stale', /** * @description Title of needs refresh in indexed dbviews of the application panel */ someEntriesMayHaveBeenModified: 'Some entries may have been modified', /** * @description Text in DOMStorage Items View of the Application panel */ keyString: 'Key', /** * @description Text in Indexed DBViews of the Application panel */ primaryKey: 'Primary key', /** * @description Text for the value of something */ valueString: 'Value', /** * @description Data grid name for Indexed DB data grids */ indexedDb: 'Indexed DB', /** * @description Text in Indexed DBViews of the Application panel */ keyPath: 'Key path: ', /** * @description Tooltip text that appears when hovering over the triangle left button in the Indexed DBViews of the Application panel */ showPreviousPage: 'Show previous page', /** * @description Tooltip text that appears when hovering over the triangle right button in the Indexed DBViews of the Application panel */ showNextPage: 'Show next page', /** * @description Text in Indexed DBViews of the Application panel */ filterByKey: 'Filter by key (show keys greater or equal to)', /** * @description Text in Context menu for expanding objects in IndexedDB tables */ expandRecursively: 'Expand Recursively', /** * @description Text in Context menu for collapsing objects in IndexedDB tables */ collapse: 'Collapse', /** * @description Span text content in Indexed DBViews of the Application panel * @example {2} PH1 */ totalEntriesS: 'Total entries: {PH1}', /** * @description Text in Indexed DBViews of the Application panel * @example {2} PH1 */ keyGeneratorValueS: 'Key generator value: {PH1}', } as const; const str_ = i18n.i18n.registerUIStrings('panels/application/IndexedDBViews.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); const {repeat} = Directives; const {widget} = UI.Widget; export class IDBDatabaseView extends ApplicationComponents.StorageMetadataView.StorageMetadataView { private readonly model: IndexedDBModel; private database!: Database; constructor(model: IndexedDBModel, database: Database|null) { super(); this.model = model; this.setShowOnlyBucket(true); if (database) { this.update(database); } } override getTitle(): string|undefined { return this.database?.databaseId.name; } override async renderReportContent(): Promise<LitTemplate> { if (!this.database) { return nothing; } return html` ${await super.renderReportContent()} ${this.key(i18nString(UIStrings.version))} ${this.value(this.database.version.toString())} ${this.key(i18nString(UIStrings.objectStores))} ${this.value(this.database.objectStores.size.toString())} <devtools-report-divider></devtools-report-divider> <devtools-report-section> <devtools-button aria-label=${i18nString(UIStrings.deleteDatabase)} .variant=${Buttons.Button.Variant.OUTLINED} @click=${this.deleteDatabase} jslog=${VisualLogging.action('delete-database').track({ click: true, })}> ${i18nString(UIStrings.deleteDatabase)} </devtools-button>&nbsp; <devtools-button aria-label=${i18nString(UIStrings.refreshDatabase)} .variant=${Buttons.Button.Variant.OUTLINED} @click=${this.refreshDatabaseButtonClicked} jslog=${VisualLogging.action('refresh-database').track({ click: true, })}> ${i18nString(UIStrings.refreshDatabase)} </devtools-button> </devtools-report-section> `; } private refreshDatabaseButtonClicked(): void { this.model.refreshDatabase(this.database.databaseId); } update(database: Database): void { this.database = database; const bucketInfo = this.model.target() .model(SDK.StorageBucketsModel.StorageBucketsModel) ?.getBucketByName(database.databaseId.storageBucket.storageKey, database.databaseId.storageBucket.name); if (bucketInfo) { this.setStorageBucket(bucketInfo); } else { this.setStorageKey(database.databaseId.storageBucket.storageKey); } void this.render().then(() => this.updatedForTests()); } private updatedForTests(): void { // Sniffed in tests. } private async deleteDatabase(): Promise<void> { const ok = await UI.UIUtils.ConfirmDialog.show( i18nString(UIStrings.databaseWillBeRemoved), i18nString(UIStrings.confirmDeleteDatabase, {PH1: this.database.databaseId.name}), this, {jslogContext: 'delete-database-confirmation'}); if (ok) { void this.model.deleteDatabase(this.database.databaseId); } } override wasShown(): void { super.wasShown(); } } customElements.define('devtools-idb-database-view', IDBDatabaseView); declare global { interface HTMLElementTagNameMap { 'devtools-idb-database-view': IDBDatabaseView; } } export interface IndexedDBDataViewInput { isIndex: boolean; index: Index|null; objectStore: ObjectStore; entries: Entry[]; skipCount: number; selectedRowNumber: number; clearButtonEnabled: boolean; hasMore: boolean; keyFilter: string; needsRefreshVisible: boolean; metadata: ObjectStoreMetadata|null; refreshButtonClicked: () => void; clearButtonClicked: () => Promise<void>; deleteButtonClicked: () => Promise<void>; pageBackButtonClicked: () => void; pageForwardButtonClicked: () => void; onKeyFilterChange: (value: string) => void; onRowSelected: (rowNumber: number) => void; deleteEntry: (entry: Entry) => Promise<void>; populateContextMenu: (e: CustomEvent<UI.ContextMenu.ContextMenu>, entry: Entry) => void; } const renderKeyPathString = (keyPathString: string): LitTemplate => { return html`"<span class="source-code indexed-db-key-path">${keyPathString}</span>"`; }; const renderKeyColumnHeader = (prefix: string, keyPath: string|string[]|null|undefined): LitTemplate => { if (keyPath === undefined || keyPath === null || keyPath === '') { return html`${prefix}`; } return html` ${prefix} (${i18nString(UIStrings.keyPath)}${ Array.isArray(keyPath) ? html`[${keyPath.map((path, i) => html`${i > 0 ? ', ' : ''}${renderKeyPathString(path)}`)}]` : renderKeyPathString(keyPath)})`; }; const renderDataGrid = (input: IndexedDBDataViewInput): LitTemplate => { const keyPath = input.isIndex && input.index ? input.index.keyPath : input.objectStore.keyPath; // clang-format off return html`<devtools-data-grid striped style="flex: auto;" name=${i18nString(UIStrings.indexedDb)} .template=${html` <style>${indexedDBViewsStyles}</style> <table> <tr> <th id="number" fixed width="50px">#</th> <th id="key">${renderKeyColumnHeader(i18nString(UIStrings.keyString), keyPath)}</th> ${input.isIndex ? html`<th id="primary-key">${renderKeyColumnHeader(i18nString(UIStrings.primaryKey), input.objectStore.keyPath)}</th>` : nothing} <th id="value">${i18nString(UIStrings.valueString)}</th> </tr> ${repeat(input.entries, (_entry, index) => index, (entry, index) => html` <tr ?selected=${index + input.skipCount === input.selectedRowNumber} @select=${() => input.onRowSelected(index + input.skipCount)} @delete=${() => input.deleteEntry(entry)} @contextmenu=${(e: CustomEvent<UI.ContextMenu.ContextMenu>) => input.populateContextMenu(e, entry)}> <td>${index + input.skipCount}</td> <td>${widget(ObjectPropertiesSectionWidget, {value: entry.key})}</td> ${input.isIndex ? html`<td>${widget(ObjectPropertiesSectionWidget, {value: entry.primaryKey})}</td>` : nothing} <td class="value-column">${widget(ObjectPropertiesSectionWidget, {value: entry.value})}</td> </tr>` )} </table>`}> </devtools-data-grid>`; // clang-format on }; const renderToolbar = (input: IndexedDBDataViewInput): LitTemplate => { // clang-format off return html` <devtools-toolbar class="data-view-toolbar" jslog=${VisualLogging.toolbar()}> <devtools-button class="toolbar-button" .iconName=${'refresh'} .title=${i18nString(UIStrings.refresh)} jslog=${VisualLogging.action('refresh').track({click: true})} @click=${input.refreshButtonClicked} .variant=${Buttons.Button.Variant.TOOLBAR} ></devtools-button> <devtools-button class="toolbar-button" .iconName=${'clear'} .title=${i18nString(UIStrings.clearObjectStore)} jslog=${VisualLogging.action('clear-all').track({click: true})} @click=${input.clearButtonClicked} .disabled=${input.isIndex || !input.clearButtonEnabled} .variant=${Buttons.Button.Variant.TOOLBAR}> </devtools-button> <devtools-button class="toolbar-button" .iconName=${'bin'} .title=${i18nString(UIStrings.deleteSelected)} jslog=${VisualLogging.action('delete-selected').track({click: true})} @click=${input.deleteButtonClicked} .disabled=${input.selectedRowNumber < 0 || input.entries.length === 0} .variant=${Buttons.Button.Variant.TOOLBAR}> </devtools-button> <div class="toolbar-divider"></div> <devtools-button class="toolbar-button" .iconName=${'triangle-left'} .title=${i18nString(UIStrings.showPreviousPage)} .disabled=${input.skipCount <= 0} @click=${input.pageBackButtonClicked} .variant=${Buttons.Button.Variant.TOOLBAR}> </devtools-button> <devtools-button class="toolbar-button" .iconName=${'triangle-right'} .title=${i18nString(UIStrings.showNextPage)} .disabled=${!input.hasMore} @click=${input.pageForwardButtonClicked} .variant=${Buttons.Button.Variant.TOOLBAR}> </devtools-button> <devtools-toolbar-input type="filter" placeholder=${i18nString(UIStrings.filterByKey)} class="key-filter-input" .value=${input.keyFilter} @change=${(e: CustomEvent<string>) => { input.onKeyFilterChange(e.detail); }}> </devtools-toolbar-input> ${input.needsRefreshVisible ? html` <div class="toolbar-divider"></div> <div class="toolbar-item stale-data-warning" title=${ i18nString( UIStrings .someEntriesMayHaveBeenModified)}> <devtools-icon name="warning" class="warning-icon"></devtools-icon> <span>${i18nString(UIStrings.dataMayBeStale)}</span> </div> ` : nothing} </devtools-toolbar>`; // clang-format on }; const renderSummaryBar = (input: IndexedDBDataViewInput): LitTemplate => { const metadata = input.metadata; if (!metadata) { return nothing; } // clang-format off return html` <div class="object-store-summary-bar"> <span>${i18nString(UIStrings.totalEntriesS, { PH1: String(metadata.entriesCount)})}</span> ${input.objectStore.autoIncrement ? html` <span class="separator">\u2758</span> <span>${i18nString(UIStrings.keyGeneratorValueS, {PH1: String(metadata.keyGeneratorValue)})}</span>` : nothing} </div>`; // clang-format on }; export type IDBDataViewView = (input: IndexedDBDataViewInput, output: undefined, target: HTMLElement) => void; // clang-format off export const IDB_DATA_VIEW_DEFAULT_VIEW: IDBDataViewView = (input, _output, target) => { render(html` ${renderToolbar(input)} <div class="data-grid-container"> ${renderDataGrid(input)} </div> ${renderSummaryBar(input)} `, target, {container: {classes: ['indexed-db-data-view', 'storage-view']}}); }; // clang-format on export class IDBDataView extends UI.View.SimpleView { private readonly model: IndexedDBModel; private readonly databaseId: DatabaseId; private isIndex: boolean; private readonly refreshObjectStoreCallback: () => void; private clearingObjectStore: boolean; private pageSize: number; private skipCount: number; // Used in Web Tests protected entries: Entry[]; #hasMore = false; #selectedRowNumber = -1; #needsRefreshVisible = false; #clearButtonEnabled = true; #metadata: ObjectStoreMetadata|null = null; #keyFilter = ''; private objectStore!: ObjectStore; private index!: Index|null; private lastPageSize!: number; private lastSkipCount!: number; // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration) // eslint-disable-next-line @typescript-eslint/no-explicit-any private lastKey?: any; readonly #view: IDBDataViewView; constructor(model: IndexedDBModel, databaseId: DatabaseId, objectStore: ObjectStore, index: Index|null, refreshObjectStoreCallback: () => void, view = IDB_DATA_VIEW_DEFAULT_VIEW) { super({ title: i18nString(UIStrings.idb), viewId: 'idb', jslog: `${VisualLogging.pane('indexed-db-data-view')}`, }); this.#view = view; this.registerRequiredCSS(indexedDBViewsStyles); this.registerRequiredCSS(DataGrid.dataGridStyles); this.model = model; this.databaseId = databaseId; this.isIndex = Boolean(index); this.refreshObjectStoreCallback = refreshObjectStoreCallback; this.clearingObjectStore = false; this.pageSize = 50; this.skipCount = 0; this.entries = []; this.update(objectStore, index); } private pageBackButtonClicked(): void { this.skipCount = Math.max(0, this.skipCount - this.pageSize); this.updateData(false); } private pageForwardButtonClicked(): void { this.skipCount = this.skipCount + this.pageSize; this.updateData(false); } private populateContextMenu(e: CustomEvent<UI.ContextMenu.ContextMenu>, {value}: Entry): void { const contextMenu = e.detail; if (value && value.hasChildren) { const tr = e.currentTarget as HTMLElement; const valueTd = tr.querySelector('.value-column'); if (valueTd) { const widgetEl = valueTd.querySelector('devtools-widget'); if (widgetEl) { const widget = UI.Widget.Widget.get(widgetEl); if (widget instanceof ObjectPropertiesSectionWidget) { const objectUi = widget.objectPropertiesSection; if (objectUi) { contextMenu.revealSection().appendItem(i18nString(UIStrings.expandRecursively), () => { void objectUi.objectTreeElement().expandRecursively(); }, {jslogContext: 'expand-recursively'}); contextMenu.revealSection().appendItem(i18nString(UIStrings.collapse), () => { objectUi.objectTreeElement().collapse(); }, {jslogContext: 'collapse'}); } } } } } } refreshData(): void { this.updateData(true); } update(objectStore: ObjectStore|null = null, index: Index|null = null): void { if (!objectStore) { return; } this.objectStore = objectStore; this.index = index; this.#selectedRowNumber = -1; this.skipCount = 0; this.updateData(true); this.performUpdate(); } // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration) // eslint-disable-next-line @typescript-eslint/no-explicit-any private parseKey(keyString: string): any { let result; try { result = JSON.parse(keyString); } catch { result = keyString; } return result; } private updateData(force: boolean): void { const key = this.parseKey(this.#keyFilter); const pageSize = this.pageSize; let skipCount: 0|number = this.skipCount; const selected = this.#selectedRowNumber !== -1 ? this.#selectedRowNumber : 0; this.#selectedRowNumber = Math.max(selected, this.skipCount); // Page forward should select top entry if (!force && this.lastKey === key && this.lastPageSize === pageSize && this.lastSkipCount === skipCount) { return; } if (this.lastKey !== key || this.lastPageSize !== pageSize) { skipCount = 0; this.skipCount = 0; } this.lastKey = key; this.lastPageSize = pageSize; this.lastSkipCount = skipCount; function callback(this: IDBDataView, entries: Entry[], hasMore: boolean): void { this.entries = entries; this.#hasMore = hasMore; this.#needsRefreshVisible = false; if (this.entries.length === 0) { this.#selectedRowNumber = -1; } else { this.#selectedRowNumber = Math.min(this.#selectedRowNumber, this.skipCount + this.entries.length - 1); if (this.#selectedRowNumber < this.skipCount) { this.#selectedRowNumber = -1; } } this.performUpdate(); this.updatedDataForTests(); } const idbKeyRange = key ? window.IDBKeyRange.lowerBound(key) : null; if (this.isIndex && this.index) { this.model.loadIndexData(this.databaseId, this.objectStore.name, this.index.name, idbKeyRange, skipCount, pageSize, callback.bind(this)); } else { this.model.loadObjectStoreData(this.databaseId, this.objectStore.name, idbKeyRange, skipCount, pageSize, callback.bind(this)); } void this.model.getMetadata(this.databaseId, this.objectStore).then(metadata => { this.#metadata = metadata; this.performUpdate(); }); } private updatedDataForTests(): void { // Sniffed in tests. } private refreshButtonClicked(): void { this.updateData(true); } private async clearButtonClicked(): Promise<void> { const ok = await UI.UIUtils.ConfirmDialog.show(i18nString(UIStrings.objectStoreWillBeCleared), i18nString(UIStrings.confirmClearObjectStore, {PH1: this.objectStore.name}), // TODO(b/407750537): Fix the linter false positive // eslint-disable-next-line @devtools/no-imperative-dom-api this.element, {jslogContext: 'clear-object-store-confirmation'}); if (ok) { this.#clearButtonEnabled = false; this.performUpdate(); this.clearingObjectStore = true; await this.model.clearObjectStore(this.databaseId, this.objectStore.name); this.clearingObjectStore = false; this.#clearButtonEnabled = true; this.performUpdate(); this.updateData(true); } } markNeedsRefresh(): void { // We expect that calling clearObjectStore() will cause the backend to send us an update. if (this.clearingObjectStore) { return; } this.#needsRefreshVisible = true; this.performUpdate(); } private async resolveArrayKey(key: SDK.RemoteObject.RemoteObject): Promise<IDBKeyValue> { const {properties} = await key.getOwnProperties(false /* generatePreview */); if (!properties) { return []; } const result: IDBKeyValue = []; const propertyPromises = properties.filter(property => !isNaN(Number(property.name))).map(async property => { const value = property.value; if (!value) { return; } let propertyValue; if (value.subtype === 'array') { propertyValue = await this.resolveArrayKey(value); } else { propertyValue = value.value; } result[Number(property.name)] = propertyValue; }); await Promise.all(propertyPromises); return result; } private async deleteButtonClicked(): Promise<void> { if (this.#selectedRowNumber < 0) { return; } const entry = this.entries[this.#selectedRowNumber - this.skipCount]; if (entry) { await this.deleteEntry(entry); } } private async deleteEntry(entry: Entry): Promise<void> { const key = (this.isIndex ? entry.primaryKey : entry.key); const keyValue: IDBKeyValue = key.subtype === 'array' ? await this.resolveArrayKey(key) : key.value; await this.model.deleteEntries(this.databaseId, this.objectStore.name, window.IDBKeyRange.only(keyValue)); this.refreshObjectStoreCallback(); } clear(): void { this.entries = []; this.#selectedRowNumber = -1; this.performUpdate(); } private onRowSelected(rowNumber: number): void { this.#selectedRowNumber = rowNumber; this.performUpdate(); } override performUpdate(): void { this.#view({ isIndex: this.isIndex, index: this.index, objectStore: this.objectStore, entries: this.entries, skipCount: this.skipCount, selectedRowNumber: this.#selectedRowNumber, clearButtonEnabled: this.#clearButtonEnabled, hasMore: this.#hasMore, keyFilter: this.#keyFilter, needsRefreshVisible: this.#needsRefreshVisible, metadata: this.#metadata, refreshButtonClicked: this.refreshButtonClicked.bind(this), clearButtonClicked: this.clearButtonClicked.bind(this), deleteButtonClicked: this.deleteButtonClicked.bind(this), pageBackButtonClicked: this.pageBackButtonClicked.bind(this), pageForwardButtonClicked: this.pageForwardButtonClicked.bind(this), onKeyFilterChange: (value: string) => { this.#keyFilter = value; this.updateData(false); }, onRowSelected: this.onRowSelected.bind(this), deleteEntry: this.deleteEntry.bind(this), populateContextMenu: this.populateContextMenu.bind(this), }, undefined, this.element); } } interface ObjectPropertiesSectionWidgetInput { value: SDK.RemoteObject.RemoteObject|null; } interface ObjectPropertiesSectionWidgetOutput { objectPropSection: ObjectUI.ObjectPropertiesSection.ObjectPropertiesSection|null; } type ObjectPropertiesSectionWidgetView = ( input: ObjectPropertiesSectionWidgetInput, output: ObjectPropertiesSectionWidgetOutput, target: HTMLElement, ) => void; const OBJECT_PROPERTIES_SECTION_WIDGET_DEFAULT_VIEW: ObjectPropertiesSectionWidgetView = (input, output, target) => { if (!input.value) { output.objectPropSection = null; render(nothing, target); return; } const objectPropSection = ObjectUI.ObjectPropertiesSection.ObjectPropertiesSection.defaultObjectPropertiesSection( input.value, undefined /* linkifier */, true /* skipProto */, true /* readOnly */); output.objectPropSection = objectPropSection; const element = input.value.hasChildren ? objectPropSection.element : objectPropSection.titleElement; render(html`${element}`, target); }; class ObjectPropertiesSectionWidget extends UI.Widget.Widget { #value: SDK.RemoteObject.RemoteObject|null = null; #objectPropSection: ObjectUI.ObjectPropertiesSection.ObjectPropertiesSection|null = null; readonly #view: ObjectPropertiesSectionWidgetView; constructor(element?: HTMLElement, view = OBJECT_PROPERTIES_SECTION_WIDGET_DEFAULT_VIEW) { super(element); this.#view = view; } set value(value: SDK.RemoteObject.RemoteObject|null) { if (this.#value === value) { return; } this.#value = value; this.requestUpdate(); } get objectPropertiesSection(): ObjectUI.ObjectPropertiesSection.ObjectPropertiesSection|null { return this.#objectPropSection; } override performUpdate(): void { const output: ObjectPropertiesSectionWidgetOutput = {objectPropSection: null}; this.#view({value: this.#value}, output, this.contentElement); this.#objectPropSection = output.objectPropSection; } }