| // Copyright 2014 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import * as Common from '../../core/common/common.js'; |
| import * as i18n from '../../core/i18n/i18n.js'; |
| import * as Platform from '../../core/platform/platform.js'; |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import * as DataGrid from '../../ui/legacy/components/data_grid/data_grid.js'; |
| import * as UI from '../../ui/legacy/legacy.js'; |
| |
| import serviceWorkerCacheViewsStyles from './serviceWorkerCacheViews.css.js'; |
| |
| import type * as Protocol from '../../generated/protocol.js'; |
| import * as Network from '../network/network.js'; |
| |
| const UIStrings = { |
| /** |
| *@description Text in Application Panel Sidebar of the Application panel |
| */ |
| cache: 'Cache', |
| /** |
| *@description Text to refresh the page |
| */ |
| refresh: 'Refresh', |
| /** |
| *@description Tooltip text that appears when hovering over the largeicon delete button in the Service Worker Cache Views of the Application panel |
| */ |
| deleteSelected: 'Delete Selected', |
| /** |
| *@description Text in Service Worker Cache Views of the Application panel |
| */ |
| filterByPath: 'Filter by Path', |
| /** |
| *@description Text in Service Worker Cache Views of the Application panel |
| */ |
| selectACacheEntryAboveToPreview: 'Select a cache entry above to preview', |
| /** |
| *@description Text for the name of something |
| */ |
| name: 'Name', |
| /** |
| *@description Text in Service Worker Cache Views of the Application panel |
| */ |
| timeCached: 'Time Cached', |
| /** |
| * @description Tooltip text that appears when hovering over the vary header column in the Service Worker Cache Views of the Application panel |
| */ |
| varyHeaderWarning: '⚠️ Set ignoreVary to true when matching this entry', |
| /** |
| *@description Text used to show that data was retrieved from ServiceWorker Cache |
| */ |
| serviceWorkerCache: '`Service Worker` Cache', |
| /** |
| *@description Span text content in Service Worker Cache Views of the Application panel |
| *@example {2} PH1 |
| */ |
| matchingEntriesS: 'Matching entries: {PH1}', |
| /** |
| *@description Span text content in Indexed DBViews of the Application panel |
| *@example {2} PH1 |
| */ |
| totalEntriesS: 'Total entries: {PH1}', |
| /** |
| *@description Text for network request headers |
| */ |
| headers: 'Headers', |
| /** |
| *@description Text for previewing items |
| */ |
| preview: 'Preview', |
| }; |
| const str_ = i18n.i18n.registerUIStrings('panels/application/ServiceWorkerCacheViews.ts', UIStrings); |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); |
| export class ServiceWorkerCacheView extends UI.View.SimpleView { |
| private model: SDK.ServiceWorkerCacheModel.ServiceWorkerCacheModel; |
| private entriesForTest: Protocol.CacheStorage.DataEntry[]|null; |
| private readonly splitWidget: UI.SplitWidget.SplitWidget; |
| private readonly previewPanel: UI.Widget.VBox; |
| private preview: UI.Widget.Widget|null; |
| private cache: SDK.ServiceWorkerCacheModel.Cache; |
| private dataGrid: DataGrid.DataGrid.DataGridImpl<DataGridNode>|null; |
| private readonly refreshThrottler: Common.Throttler.Throttler; |
| private readonly refreshButton: UI.Toolbar.ToolbarButton; |
| private readonly deleteSelectedButton: UI.Toolbar.ToolbarButton; |
| private entryPathFilter: string; |
| private returnCount: number|null; |
| private summaryBarElement: Element|null; |
| private loadingPromise: Promise<{ |
| entries: Array<Protocol.CacheStorage.DataEntry>, |
| returnCount: number, |
| }>|null; |
| constructor(model: SDK.ServiceWorkerCacheModel.ServiceWorkerCacheModel, cache: SDK.ServiceWorkerCacheModel.Cache) { |
| super(i18nString(UIStrings.cache)); |
| |
| this.model = model; |
| this.entriesForTest = null; |
| |
| this.element.classList.add('service-worker-cache-data-view'); |
| this.element.classList.add('storage-view'); |
| |
| const editorToolbar = new UI.Toolbar.Toolbar('data-view-toolbar', this.element); |
| this.splitWidget = new UI.SplitWidget.SplitWidget(false, false); |
| this.splitWidget.show(this.element); |
| |
| this.previewPanel = new UI.Widget.VBox(); |
| const resizer = this.previewPanel.element.createChild('div', 'cache-preview-panel-resizer'); |
| this.splitWidget.setMainWidget(this.previewPanel); |
| this.splitWidget.installResizer(resizer); |
| |
| this.preview = null; |
| |
| this.cache = cache; |
| this.dataGrid = null; |
| this.refreshThrottler = new Common.Throttler.Throttler(300); |
| this.refreshButton = new UI.Toolbar.ToolbarButton(i18nString(UIStrings.refresh), 'largeicon-refresh'); |
| this.refreshButton.addEventListener(UI.Toolbar.ToolbarButton.Events.Click, this.refreshButtonClicked, this); |
| editorToolbar.appendToolbarItem(this.refreshButton); |
| |
| this.deleteSelectedButton = new UI.Toolbar.ToolbarButton(i18nString(UIStrings.deleteSelected), 'largeicon-delete'); |
| this.deleteSelectedButton.addEventListener(UI.Toolbar.ToolbarButton.Events.Click, _event => { |
| void this.deleteButtonClicked(null); |
| }); |
| editorToolbar.appendToolbarItem(this.deleteSelectedButton); |
| |
| const entryPathFilterBox = new UI.Toolbar.ToolbarInput(i18nString(UIStrings.filterByPath), '', 1); |
| editorToolbar.appendToolbarItem(entryPathFilterBox); |
| const entryPathFilterThrottler = new Common.Throttler.Throttler(300); |
| this.entryPathFilter = ''; |
| entryPathFilterBox.addEventListener(UI.Toolbar.ToolbarInput.Event.TextChanged, () => { |
| void entryPathFilterThrottler.schedule(() => { |
| this.entryPathFilter = entryPathFilterBox.value(); |
| return this.updateData(true); |
| }); |
| }); |
| this.returnCount = (null as number | null); |
| this.summaryBarElement = (null as Element | null); |
| this.loadingPromise = null; |
| |
| this.update(cache); |
| } |
| |
| private resetDataGrid(): void { |
| if (this.dataGrid) { |
| this.dataGrid.asWidget().detach(); |
| } |
| this.dataGrid = this.createDataGrid(); |
| const dataGridWidget = this.dataGrid.asWidget(); |
| this.splitWidget.setSidebarWidget(dataGridWidget); |
| dataGridWidget.setMinimumSize(0, 250); |
| } |
| |
| wasShown(): void { |
| this.model.addEventListener( |
| SDK.ServiceWorkerCacheModel.Events.CacheStorageContentUpdated, this.cacheContentUpdated, this); |
| this.registerCSSFiles([serviceWorkerCacheViewsStyles]); |
| void this.updateData(true); |
| } |
| |
| willHide(): void { |
| this.model.removeEventListener( |
| SDK.ServiceWorkerCacheModel.Events.CacheStorageContentUpdated, this.cacheContentUpdated, this); |
| } |
| |
| private showPreview(preview: UI.Widget.Widget|null): void { |
| if (preview && this.preview === preview) { |
| return; |
| } |
| if (this.preview) { |
| this.preview.detach(); |
| } |
| if (!preview) { |
| preview = new UI.EmptyWidget.EmptyWidget(i18nString(UIStrings.selectACacheEntryAboveToPreview)); |
| } |
| this.preview = preview; |
| this.preview.show(this.previewPanel.element); |
| } |
| |
| private createDataGrid(): DataGrid.DataGrid.DataGridImpl<DataGridNode> { |
| const columns = ([ |
| {id: 'number', title: '#', sortable: false, width: '3px'}, |
| {id: 'name', title: i18nString(UIStrings.name), weight: 4, sortable: true}, |
| { |
| id: 'responseType', |
| title: i18n.i18n.lockedString('Response-Type'), |
| weight: 1, |
| align: DataGrid.DataGrid.Align.Right, |
| sortable: true, |
| }, |
| {id: 'contentType', title: i18n.i18n.lockedString('Content-Type'), weight: 1, sortable: true}, |
| { |
| id: 'contentLength', |
| title: i18n.i18n.lockedString('Content-Length'), |
| weight: 1, |
| align: DataGrid.DataGrid.Align.Right, |
| sortable: true, |
| }, |
| { |
| id: 'responseTime', |
| title: i18nString(UIStrings.timeCached), |
| width: '12em', |
| weight: 1, |
| align: DataGrid.DataGrid.Align.Right, |
| sortable: true, |
| }, |
| {id: 'varyHeader', title: i18n.i18n.lockedString('Vary Header'), weight: 1, sortable: true}, |
| ] as DataGrid.DataGrid.ColumnDescriptor[]); |
| const dataGrid = new DataGrid.DataGrid.DataGridImpl({ |
| displayName: i18nString(UIStrings.serviceWorkerCache), |
| columns, |
| deleteCallback: this.deleteButtonClicked.bind(this), |
| refreshCallback: this.updateData.bind(this, true), |
| editCallback: undefined, |
| }); |
| |
| dataGrid.addEventListener(DataGrid.DataGrid.Events.SortingChanged, this.sortingChanged, this); |
| |
| dataGrid.addEventListener(DataGrid.DataGrid.Events.SelectedNode, event => { |
| void this.previewCachedResponse(event.data.data as SDK.NetworkRequest.NetworkRequest); |
| }, this); |
| dataGrid.setStriped(true); |
| return dataGrid; |
| } |
| |
| private sortingChanged(): void { |
| if (!this.dataGrid) { |
| return; |
| } |
| |
| const dataGrid = this.dataGrid; |
| |
| const accending = dataGrid.isSortOrderAscending(); |
| const columnId = dataGrid.sortColumnId(); |
| let comparator: (arg0: DataGridNode, arg1: DataGridNode) => number; |
| if (columnId === 'name') { |
| comparator = (a: DataGridNode, b: DataGridNode): number => a.name.localeCompare(b.name); |
| } else if (columnId === 'contentType') { |
| comparator = (a: DataGridNode, b: DataGridNode): number => a.data.mimeType.localeCompare(b.data.mimeType); |
| } else if (columnId === 'contentLength') { |
| comparator = (a: DataGridNode, b: DataGridNode): number => a.data.resourceSize - b.data.resourceSize; |
| } else if (columnId === 'responseTime') { |
| comparator = (a: DataGridNode, b: DataGridNode): number => a.data.endTime - b.data.endTime; |
| } else if (columnId === 'responseType') { |
| comparator = (a: DataGridNode, b: DataGridNode): number => a.responseType.localeCompare(b.responseType); |
| } else if (columnId === 'varyHeader') { |
| comparator = (a: DataGridNode, b: DataGridNode): number => a.varyHeader.localeCompare(b.varyHeader); |
| } |
| |
| const children = dataGrid.rootNode().children.slice(); |
| dataGrid.rootNode().removeChildren(); |
| children.sort((a, b) => { |
| const result = comparator((a as DataGridNode), (b as DataGridNode)); |
| return accending ? result : -result; |
| }); |
| children.forEach(child => dataGrid.rootNode().appendChild(child)); |
| } |
| |
| private async deleteButtonClicked(node: DataGrid.DataGrid.DataGridNode<DataGridNode>|null): Promise<void> { |
| if (!node) { |
| node = this.dataGrid && this.dataGrid.selectedNode; |
| if (!node) { |
| return; |
| } |
| } |
| await this.model.deleteCacheEntry(this.cache, (node.data.url() as string)); |
| node.remove(); |
| } |
| |
| update(cache: SDK.ServiceWorkerCacheModel.Cache): void { |
| this.cache = cache; |
| this.resetDataGrid(); |
| void this.updateData(true); |
| } |
| |
| private updateSummaryBar(): void { |
| if (!this.summaryBarElement) { |
| this.summaryBarElement = this.element.createChild('div', 'cache-storage-summary-bar'); |
| } |
| this.summaryBarElement.removeChildren(); |
| |
| const span = this.summaryBarElement.createChild('span'); |
| if (this.entryPathFilter) { |
| span.textContent = i18nString(UIStrings.matchingEntriesS, {PH1: String(this.returnCount)}); |
| } else { |
| span.textContent = i18nString(UIStrings.totalEntriesS, {PH1: String(this.returnCount)}); |
| } |
| } |
| |
| private updateDataCallback( |
| this: ServiceWorkerCacheView, skipCount: number, entries: Protocol.CacheStorage.DataEntry[], |
| returnCount: number): void { |
| if (!this.dataGrid) { |
| return; |
| } |
| const selected = this.dataGrid.selectedNode && this.dataGrid.selectedNode.data.url(); |
| this.refreshButton.setEnabled(true); |
| this.entriesForTest = entries; |
| this.returnCount = returnCount; |
| this.updateSummaryBar(); |
| |
| const oldEntries = new Map<string, DataGridNode>(); |
| const rootNode = this.dataGrid.rootNode(); |
| for (const node of rootNode.children) { |
| oldEntries.set(node.data.url, (node as DataGridNode)); |
| } |
| rootNode.removeChildren(); |
| let selectedNode: DataGridNode|null = null; |
| for (let i = 0; i < entries.length; ++i) { |
| const entry = entries[i]; |
| let node = oldEntries.get(entry.requestURL); |
| if (!node || node.data.responseTime !== entry.responseTime) { |
| node = new DataGridNode(i, this.createRequest(entry), entry.responseType); |
| node.selectable = true; |
| } else { |
| node.data.number = i; |
| } |
| rootNode.appendChild(node); |
| if (entry.requestURL === selected) { |
| selectedNode = node; |
| } |
| } |
| if (!selectedNode) { |
| this.showPreview(null); |
| } else { |
| selectedNode.revealAndSelect(); |
| } |
| this.updatedForTest(); |
| } |
| |
| private async updateData(force: boolean): Promise<{ |
| entries: Protocol.CacheStorage.DataEntry[], |
| returnCount: number, |
| }|undefined> { |
| if (!force && this.loadingPromise) { |
| return this.loadingPromise; |
| } |
| this.refreshButton.setEnabled(false); |
| |
| if (this.loadingPromise) { |
| return this.loadingPromise; |
| } |
| |
| this.loadingPromise = new Promise(resolve => { |
| this.model.loadAllCacheData( |
| this.cache, this.entryPathFilter, (entries: Protocol.CacheStorage.DataEntry[], returnCount: number) => { |
| resolve({entries, returnCount}); |
| }); |
| }); |
| |
| const {entries, returnCount} = await this.loadingPromise; |
| this.updateDataCallback(0, entries, returnCount); |
| this.loadingPromise = null; |
| return; |
| } |
| |
| private refreshButtonClicked(): void { |
| void this.updateData(true); |
| } |
| |
| private cacheContentUpdated( |
| event: Common.EventTarget.EventTargetEvent<SDK.ServiceWorkerCacheModel.CacheStorageContentUpdatedEvent>): void { |
| const {cacheName, origin} = event.data; |
| if (this.cache.securityOrigin !== origin || this.cache.cacheName !== cacheName) { |
| return; |
| } |
| void this.refreshThrottler.schedule(() => Promise.resolve(this.updateData(true)), true); |
| } |
| |
| private async previewCachedResponse(request: SDK.NetworkRequest.NetworkRequest): Promise<void> { |
| let preview = networkRequestToPreview.get(request); |
| if (!preview) { |
| preview = new RequestView(request); |
| networkRequestToPreview.set(request, preview); |
| } |
| |
| // It is possible that table selection changes before the preview opens. |
| if (this.dataGrid && this.dataGrid.selectedNode && request === this.dataGrid.selectedNode.data) { |
| this.showPreview(preview); |
| } |
| } |
| |
| private createRequest(entry: Protocol.CacheStorage.DataEntry): SDK.NetworkRequest.NetworkRequest { |
| const request = SDK.NetworkRequest.NetworkRequest.createWithoutBackendRequest( |
| 'cache-storage-' + entry.requestURL, entry.requestURL as Platform.DevToolsPath.UrlString, |
| Platform.DevToolsPath.EmptyUrlString, null); |
| request.requestMethod = entry.requestMethod; |
| request.setRequestHeaders(entry.requestHeaders); |
| request.statusCode = entry.responseStatus; |
| request.statusText = entry.responseStatusText; |
| request.protocol = new Common.ParsedURL.ParsedURL(entry.requestURL).scheme; |
| request.responseHeaders = entry.responseHeaders; |
| request.setRequestHeadersText(''); |
| request.endTime = entry.responseTime; |
| |
| let header = entry.responseHeaders.find(header => header.name.toLowerCase() === 'content-type'); |
| const contentType = header ? header.value : SDK.NetworkRequest.MIME_TYPE.PLAIN; |
| request.mimeType = contentType as SDK.NetworkRequest.MIME_TYPE; |
| |
| header = entry.responseHeaders.find(header => header.name.toLowerCase() === 'content-length'); |
| request.resourceSize = (header && Number(header.value)) || 0; |
| |
| let resourceType = Common.ResourceType.ResourceType.fromMimeType(contentType); |
| if (!resourceType) { |
| resourceType = |
| Common.ResourceType.ResourceType.fromURL(entry.requestURL) || Common.ResourceType.resourceTypes.Other; |
| } |
| request.setResourceType(resourceType); |
| request.setContentDataProvider(this.requestContent.bind(this, request)); |
| return request; |
| } |
| |
| private async requestContent(request: SDK.NetworkRequest.NetworkRequest): Promise<SDK.NetworkRequest.ContentData> { |
| const isText = request.resourceType().isTextType(); |
| const contentData: SDK.NetworkRequest.ContentData = {error: null, content: null, encoded: !isText}; |
| const response = await this.cache.requestCachedResponse(request.url(), request.requestHeaders()); |
| if (response) { |
| contentData.content = isText ? window.atob(response.body) : response.body; |
| } |
| return contentData; |
| } |
| |
| private updatedForTest(): void { |
| } |
| |
| // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration) |
| // eslint-disable-next-line @typescript-eslint/naming-convention |
| private static readonly previewSymbol = Symbol('preview'); |
| } |
| |
| const networkRequestToPreview = new WeakMap<SDK.NetworkRequest.NetworkRequest, RequestView>(); |
| |
| export class DataGridNode extends DataGrid.DataGrid.DataGridNode<DataGridNode> { |
| private number: number; |
| name: string; |
| private request: SDK.NetworkRequest.NetworkRequest; |
| responseType: Protocol.CacheStorage.CachedResponseType; |
| varyHeader: string; |
| |
| constructor( |
| number: number, request: SDK.NetworkRequest.NetworkRequest, |
| responseType: Protocol.CacheStorage.CachedResponseType) { |
| super(request); |
| this.number = number; |
| const parsed = new Common.ParsedURL.ParsedURL(request.url()); |
| if (parsed.isValid) { |
| this.name = Platform.StringUtilities.trimURL(request.url(), parsed.domain()); |
| } else { |
| this.name = request.url(); |
| } |
| this.request = request; |
| this.responseType = responseType; |
| this.varyHeader = request.responseHeaders.find(header => header.name.toLowerCase() === 'vary')?.value || ''; |
| } |
| |
| createCell(columnId: string): HTMLElement { |
| const cell = this.createTD(columnId); |
| let value; |
| let tooltip = this.request.url() as string; |
| if (columnId === 'number') { |
| value = String(this.number); |
| } else if (columnId === 'name') { |
| value = this.name; |
| } else if (columnId === 'responseType') { |
| if (this.responseType === 'opaqueResponse') { |
| value = 'opaque'; |
| } else if (this.responseType === 'opaqueRedirect') { |
| value = 'opaqueredirect'; |
| } else { |
| value = this.responseType; |
| } |
| } else if (columnId === 'contentType') { |
| value = this.request.mimeType; |
| } else if (columnId === 'contentLength') { |
| value = (this.request.resourceSize | 0).toLocaleString('en-US'); |
| } else if (columnId === 'responseTime') { |
| value = new Date(this.request.endTime * 1000).toLocaleString(); |
| } else if (columnId === 'varyHeader') { |
| value = this.varyHeader; |
| if (this.varyHeader) { |
| tooltip = i18nString(UIStrings.varyHeaderWarning); |
| } |
| } |
| const parentElement = cell.parentElement; |
| let gridNode; |
| if (parentElement && this.dataGrid) { |
| gridNode = this.dataGrid.elementToDataGridNode.get(parentElement); |
| } |
| DataGrid.DataGrid.DataGridImpl.setElementText(cell, value || '', /* longText= */ true, gridNode); |
| UI.Tooltip.Tooltip.install(cell, tooltip); |
| return cell; |
| } |
| } |
| |
| export class RequestView extends UI.Widget.VBox { |
| private tabbedPane: UI.TabbedPane.TabbedPane; |
| private resourceViewTabSetting: Common.Settings.Setting<string>; |
| |
| constructor(request: SDK.NetworkRequest.NetworkRequest) { |
| super(); |
| |
| this.tabbedPane = new UI.TabbedPane.TabbedPane(); |
| this.tabbedPane.addEventListener(UI.TabbedPane.Events.TabSelected, this.tabSelected, this); |
| this.resourceViewTabSetting = Common.Settings.Settings.instance().createSetting('cacheStorageViewTab', 'preview'); |
| |
| this.tabbedPane.appendTab( |
| 'headers', i18nString(UIStrings.headers), new Network.RequestHeadersView.RequestHeadersView(request)); |
| this.tabbedPane.appendTab( |
| 'preview', i18nString(UIStrings.preview), new Network.RequestPreviewView.RequestPreviewView(request)); |
| this.tabbedPane.show(this.element); |
| } |
| |
| wasShown(): void { |
| super.wasShown(); |
| this.selectTab(); |
| } |
| |
| private selectTab(tabId?: string): void { |
| if (!tabId) { |
| tabId = this.resourceViewTabSetting.get(); |
| } |
| if (tabId && !this.tabbedPane.selectTab(tabId)) { |
| this.tabbedPane.selectTab('headers'); |
| } |
| } |
| |
| private tabSelected(event: Common.EventTarget.EventTargetEvent<UI.TabbedPane.EventData>): void { |
| if (!event.data.isUserGesture) { |
| return; |
| } |
| this.resourceViewTabSetting.set(event.data.tabId); |
| } |
| } |