UNPKG

chrome-devtools-frontend

Version:
421 lines (388 loc) • 18.7 kB
// Copyright 2021 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* * Copyright (C) 2007, 2008 Apple Inc. All rights reserved. * Copyright (C) IBM Corp. 2009 All rights reserved. * Copyright (C) 2010 Google Inc. All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of * its contributors may be used to endorse or promote products derived * from this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ import * as Common from '../../core/common/common.js'; import * as Host from '../../core/host/host.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 ObjectUI from '../../ui/legacy/components/object_ui/object_ui.js'; // eslint-disable-next-line @devtools/es-modules-import import objectPropertiesSectionStyles from '../../ui/legacy/components/object_ui/objectPropertiesSection.css.js'; // eslint-disable-next-line @devtools/es-modules-import import objectValueStyles from '../../ui/legacy/components/object_ui/objectValue.css.js'; import * as UI from '../../ui/legacy/legacy.js'; import {Directives, html, type LitTemplate, render, type TemplateResult} from '../../ui/lit/lit.js'; import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; import requestPayloadTreeStyles from './requestPayloadTree.css.js'; import requestPayloadViewStyles from './requestPayloadView.css.js'; import {ShowMoreDetailsWidget} from './ShowMoreDetailsWidget.js'; const {classMap} = Directives; const {widgetConfig} = UI.Widget; const UIStrings = { /** * @description A context menu item Payload View of the Network panel to copy a parsed value. */ copyValue: 'Copy value', /** * @description A context menu item Payload View of the Network panel to copy the payload. */ copyPayload: 'Copy', /** * @description Text in Request Payload View of the Network panel. This is a noun-phrase meaning the * payload of a network request. */ requestPayload: 'Request Payload', /** * @description Text in Request Payload View of the Network panel */ unableToDecodeValue: '(unable to decode value)', /** * @description Text in Request Payload View of the Network panel */ queryStringParameters: 'Query String Parameters', /** * @description Text in Request Payload View of the Network panel */ formData: 'Form Data', /** * @description Text for toggling the view of payload data (e.g. query string parameters) from source to parsed in the payload tab */ viewParsed: 'View parsed', /** * @description Text to show an item is empty */ empty: '(empty)', /** * @description Text for toggling the view of payload data (e.g. query string parameters) from parsed to source in the payload tab */ viewSource: 'View source', /** * @description Text for toggling payload data (e.g. query string parameters) from decoded to * encoded in the payload tab or in the cookies preview. URL-encoded is a different data format for * the same data, which the user sees when they click this command. */ viewUrlEncoded: 'View URL-encoded', /** * @description Text for toggling payload data (e.g. query string parameters) from encoded to decoded in the payload tab or in the cookies preview */ viewDecoded: 'View decoded', } as const; const str_ = i18n.i18n.registerUIStrings('panels/network/RequestPayloadView.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); interface ViewInput { decodeRequestParameters: boolean; setURLDecoding(value: boolean): void; viewQueryParamSource: boolean; setViewQueryParamSource(value: boolean): void; viewFormParamSource: boolean; setViewFormParamSource(value: boolean): void; viewJSONPayloadSource: boolean; setViewJSONPayloadSource(value: boolean): void; copyValue(value: string): void; formData: string|undefined; formParameters: SDK.NetworkRequest.NameValue[]|undefined; queryString: string|null; queryParameters: SDK.NetworkRequest.NameValue[]|null; } type View = (input: ViewInput, output: object, target: HTMLElement) => void; export const DEFAULT_VIEW: View = (input, output, target) => { const createViewSourceToggle = (viewSource: boolean, callback: (value: boolean) => void): LitTemplate => html`<devtools-button class="payload-toggle" jslog=${VisualLogging.action().track({click: true}).context('source-parse')} .variant=${Buttons.Button.Variant.OUTLINED} @click=${(e: Event) => { e.consume(); callback(!viewSource); }}> ${viewSource ? i18nString(UIStrings.viewParsed) : i18nString(UIStrings.viewSource)} </devtools-button>`; const copyValueContextmenu = (title: string, value: () => string, jslogContext: string) => (e: Event) => { e.consume(true); const contextMenu = new UI.ContextMenu.ContextMenu(e); const copyValueHandler = (): void => input.copyValue(value()); contextMenu.clipboardSection().appendItem(title, copyValueHandler, {jslogContext}); void contextMenu.show(); }; const createSourceText = (text: string): TemplateResult => html`<li role=treeitem @contextmenu=${copyValueContextmenu(i18nString(UIStrings.copyPayload), () => text, 'copy-payload')}> <devtools-widget class='payload-value source-code' .widgetConfig=${widgetConfig(ShowMoreDetailsWidget, {text})}> </devtools-widget> </li>`; const createParsedParams = (params: SDK.NetworkRequest.NameValue[]): TemplateResult[] => params.map( param => html`<li role=treeitem @contextmenu=${ copyValueContextmenu(i18nString(UIStrings.copyValue), () => decodeURIComponent(param.value), 'copy-value')}>${ param.name !== '' ? html`${RequestPayloadView.formatParameter(param.name, 'payload-name', input.decodeRequestParameters)}${ RequestPayloadView.formatParameter( param.value, 'payload-value source-code', input.decodeRequestParameters)}` : RequestPayloadView.formatParameter( i18nString(UIStrings.empty), 'empty-request-payload', input.decodeRequestParameters)}</li>`); const parsedFormData = (() => { if (input.formData && !input.formParameters) { try { return JSON.parse(input.formData); } catch { } return undefined; } })(); const createPayload = (parsedFormData: unknown): TemplateResult => { const object = new SDK.RemoteObject.LocalJSONObject(parsedFormData); const section = new ObjectUI.ObjectPropertiesSection.RootElement(new ObjectUI.ObjectPropertiesSection.ObjectTree(object)); section.title = document.createTextNode(object.description); section.listItemElement.classList.add('source-code', 'object-properties-section'); section.childrenListElement.classList.add('source-code', 'object-properties-section'); section.expand(); return html`<devtools-tree-wrapper .treeElement=${section}></devtools-tree-wrapper>`; }; const queryStringExpandedSetting = Common.Settings.Settings.instance().createSetting('request-info-query-string-category-expanded', true); const formDataExpandedSetting = Common.Settings.Settings.instance().createSetting('request-info-form-data-category-expanded', true); const requestPayloadExpandedSetting = Common.Settings.Settings.instance().createSetting('request-info-request-payload-category-expanded', true); const toggleURLDecoding = (e: Event): void => { e.consume(); input.setURLDecoding(!input.decodeRequestParameters); }; const onContextMenu = (viewSource: boolean, callback: (value: boolean) => void, includeURLDecodingOption = true) => ( event: Event): void => { const contextMenu = new UI.ContextMenu.ContextMenu(event); const section = contextMenu.newSection(); if (viewSource) { section.appendItem(i18nString(UIStrings.viewParsed), () => callback(!viewSource), {jslogContext: 'view-parsed'}); } else { section.appendItem(i18nString(UIStrings.viewSource), () => callback(!viewSource), {jslogContext: 'view-source'}); if (includeURLDecodingOption) { const viewURLEncodedText = input.decodeRequestParameters ? i18nString(UIStrings.viewUrlEncoded) : i18nString(UIStrings.viewDecoded); section.appendItem( viewURLEncodedText, toggleURLDecoding.bind(this, event), {jslogContext: 'toggle-url-decoding'}); } } void contextMenu.show(); }; // clang-format off render(html`<style>${requestPayloadViewStyles}</style> <devtools-tree dense class=request-payload-tree .template=${html` <style>${objectValueStyles}</style> <style>${objectPropertiesSectionStyles}</style> <style>${requestPayloadTreeStyles}</style> <ul role=tree> <li role=treeitem ?hidden=${!input.queryParameters} jslog=${VisualLogging.section().context('query-string')} @contextmenu=${onContextMenu(input.viewQueryParamSource, input.setViewQueryParamSource)} @expanded=${(e: UI.TreeOutline.TreeViewElement.ExpandEvent) => queryStringExpandedSetting.set(e.detail.expanded)} > <div class="selection fill"></div>${i18nString(UIStrings.queryStringParameters)}<span class=payload-count>${`\xA0(${input.queryParameters?.length ?? 0})`}</span>${ createViewSourceToggle(input.viewQueryParamSource, input.setViewQueryParamSource)} <devtools-button class=payload-toggle ?hidden=${input.viewQueryParamSource} jslog=${VisualLogging.action().track({click: true}).context('decode-encode')} .variant=${Buttons.Button.Variant.OUTLINED} @click=${toggleURLDecoding}> ${input.decodeRequestParameters ? i18nString(UIStrings.viewUrlEncoded) : i18nString(UIStrings.viewDecoded)} </devtools-button> <ul role=group ?hidden=${!queryStringExpandedSetting.get()}> ${input.viewQueryParamSource ? createSourceText(input.queryString ?? '') : createParsedParams(input.queryParameters ?? [])} </ul> </li> <li role=treeitem ?hidden=${!input.formData || !input.formParameters} jslog=${VisualLogging.section().context('form-data')} @contextmenu=${onContextMenu(input.viewFormParamSource, input.setViewFormParamSource)} @expanded=${(e: UI.TreeOutline.TreeViewElement.ExpandEvent) => formDataExpandedSetting.set(e.detail.expanded)} > <div class="selection fill"></div>${i18nString(UIStrings.formData)}<span class=payload-count>${`\xA0(${input.formParameters?.length ?? 0})`}</span>${ createViewSourceToggle(input.viewFormParamSource, input.setViewFormParamSource)} <devtools-button class=payload-toggle ?hidden=${input.viewFormParamSource} jslog=${VisualLogging.action().track({click: true}).context('decode-encode')} .variant=${Buttons.Button.Variant.OUTLINED} @click=${toggleURLDecoding}> ${input.decodeRequestParameters ? i18nString(UIStrings.viewUrlEncoded) : i18nString(UIStrings.viewDecoded)} </devtools-button> <ul role=group ?hidden=${!formDataExpandedSetting.get()}>> ${input.viewFormParamSource ? createSourceText(input.formData ?? '') : createParsedParams(input.formParameters ?? [])} </ul> </li> <li role=treeitem ?hidden=${!input.formData || Boolean(input.formParameters)} jslog=${VisualLogging.section().context('request-payload')} @contextmenu=${onContextMenu(input.viewJSONPayloadSource, input.setViewJSONPayloadSource, /* includeURLDecodingOption*/ false)} @expanded=${(e: UI.TreeOutline.TreeViewElement.ExpandEvent) => requestPayloadExpandedSetting.set(e.detail.expanded)} > <div class="selection fill"></div>${i18nString(UIStrings.requestPayload)}${ createViewSourceToggle(input.viewJSONPayloadSource, input.setViewJSONPayloadSource)} <ul role=group ?hidden=${!requestPayloadExpandedSetting.get()}> ${!parsedFormData || input.viewJSONPayloadSource ? createSourceText(input.formData ?? '') : createPayload(parsedFormData)} </ul> </li> </ul> `}></devtools-tree> `, target); // clang-format on }; export class RequestPayloadView extends UI.Widget.VBox { #request?: SDK.NetworkRequest.NetworkRequest; #decodeRequestParameters = true; #formData?: string; #formParameters?: SDK.NetworkRequest.NameValue[]; #view: View; #viewJSONPayloadSource = false; #viewFormParamSource = false; #viewQueryParamSource = false; constructor(target?: HTMLElement, view = DEFAULT_VIEW) { super({jslog: `${VisualLogging.pane('payload').track({resize: true})}`, classes: ['request-payload-view']}); this.#view = view; } set request(request: SDK.NetworkRequest.NetworkRequest) { if (this.#request) { this.#request.removeEventListener(SDK.NetworkRequest.Events.REQUEST_HEADERS_CHANGED, this.#refreshFormData, this); } this.#request = request; const contentType = request.requestContentType(); if (contentType) { this.#decodeRequestParameters = Boolean(contentType.match(/^application\/x-www-form-urlencoded\s*(;.*)?$/i)); } if (this.isShowing()) { this.#request?.addEventListener(SDK.NetworkRequest.Events.REQUEST_HEADERS_CHANGED, this.#refreshFormData, this); } this.requestUpdate(); void this.#refreshFormData(); } get request(): SDK.NetworkRequest.NetworkRequest|undefined { return this.#request; } override wasShown(): void { super.wasShown(); this.request?.addEventListener(SDK.NetworkRequest.Events.REQUEST_HEADERS_CHANGED, this.#refreshFormData, this); void this.#refreshFormData(); } override willHide(): void { super.willHide(); this.request?.removeEventListener(SDK.NetworkRequest.Events.REQUEST_HEADERS_CHANGED, this.#refreshFormData, this); } private addEntryContextMenuHandler( treeElement: UI.TreeOutline.TreeElement, menuItem: string, jslogContext: string, getValue: () => string): void { treeElement.listItemElement.addEventListener('contextmenu', event => { event.consume(true); const contextMenu = new UI.ContextMenu.ContextMenu(event); const copyValueHandler = (): void => { Host.userMetrics.actionTaken(Host.UserMetrics.Action.NetworkPanelCopyValue); Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(getValue()); }; contextMenu.clipboardSection().appendItem(menuItem, copyValueHandler, {jslogContext}); void contextMenu.show(); }); } override performUpdate(): void { if (!this.request) { return; } const input: ViewInput = { queryString: this.request.queryString(), queryParameters: this.request.queryParameters, formData: this.#formData, formParameters: this.#formParameters, decodeRequestParameters: this.#decodeRequestParameters, setURLDecoding: (value: boolean): void => { this.#decodeRequestParameters = value; this.requestUpdate(); }, viewQueryParamSource: this.#viewQueryParamSource, setViewQueryParamSource: (value: boolean): void => { this.#viewQueryParamSource = value; this.requestUpdate(); }, viewFormParamSource: this.#viewFormParamSource, setViewFormParamSource: (value: boolean): void => { this.#viewFormParamSource = value; this.requestUpdate(); }, viewJSONPayloadSource: this.#viewJSONPayloadSource, setViewJSONPayloadSource: (value: boolean): void => { this.#viewJSONPayloadSource = value; this.requestUpdate(); }, copyValue: (value: string): void => { Host.userMetrics.actionTaken(Host.UserMetrics.Action.NetworkPanelCopyValue); Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(value); } }; this.#view(input, {}, this.element); } async #refreshFormData(): Promise<void> { this.#formData = await this.request?.requestFormData() ?? undefined; if (this.#formData) { this.#formParameters = await this.request?.formParameters() ?? undefined; } this.requestUpdate(); } static formatParameter(value: string, className: string, decodeParameters: boolean): LitTemplate { let errorDecoding = false; if (decodeParameters) { value = value.replace(/\+/g, ' '); if (value.indexOf('%') >= 0) { try { value = decodeURIComponent(value); } catch { errorDecoding = true; } } } const classes = classMap({[className]: !!className, 'empty-value': value === ''}); return html`<div class=${classes}> ${ errorDecoding ? html`<span class=payload-decode-error>${i18nString(UIStrings.unableToDecodeValue)}</span>` : value} </div>`; } }