chrome-devtools-frontend
Version:
Chrome DevTools UI
519 lines (482 loc) • 22.1 kB
text/typescript
// 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 Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as TextUtils from '../../models/text_utils/text_utils.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, nothing, render, type TemplateResult} from '../../ui/lit/lit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import {BinaryResourceView} from './BinaryResourceView.js';
import requestPayloadTreeStyles from './requestPayloadTree.css.js';
import requestPayloadViewStyles from './requestPayloadView.css.js';
import {ShowMoreDetailsWidget} from './ShowMoreDetailsWidget.js';
const {classMap} = Directives;
const {widget} = UI.Widget;
const {ifExpanded} = UI.TreeOutline;
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 {
decodeQueryParameters: boolean;
setDecodeQueryParameters(value: boolean): void;
decodeFormParameters: boolean;
setDecodeFormParameters(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;
/** Raw binary content data for the request body (when base64-encoded by backend). */
binaryPayloadContentData: TextUtils.ContentData.ContentData|null;
requestUrl: Platform.DevToolsPath.UrlString;
}
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' ${widget(ShowMoreDetailsWidget, {text})}>
</devtools-widget>
</li>`;
const createParsedParams = (params: SDK.NetworkRequest.NameValue[],
decodeParameters: boolean): TemplateResult[] => params.map(param => {
// clang-format off
return html`
<li role=treeitem
@contextmenu=${copyValueContextmenu(
i18nString(UIStrings.copyValue),
() => decodeURIComponent(param.value),
'copy-value'
)}>
${param.name !== '' ? html`
${RequestPayloadView.formatParameter(param.name, 'payload-name', decodeParameters)}
${RequestPayloadView.formatParameter(param.value, 'payload-value source-code', decodeParameters)}
` : RequestPayloadView.formatParameter(
i18nString(UIStrings.empty),
'empty-request-payload',
decodeParameters
)}
</li>
`;
// clang-format on
});
const parsedFormData = (() => {
if (input.formData && !input.formParameters) {
try {
return JSON.parse(input.formData);
} catch {
}
}
return undefined;
})();
const createPayload = (parsedFormData: unknown): LitTemplate => {
if (!parsedFormData) {
return nothing;
}
const object = new SDK.RemoteObject.LocalJSONObject(parsedFormData);
const objectTree = new ObjectUI.ObjectPropertiesSection.ObjectTree(object, {
readOnly: true,
propertiesMode: ObjectUI.ObjectPropertiesSection.ObjectPropertiesMode.OWN_AND_INTERNAL_AND_INHERITED,
});
return html`
<li role=treeitem class="source-code object-properties-section-root-element object-properties-section" open>
${object.description}
${object.hasChildren ? ObjectUI.ObjectPropertiesSection.renderObjectTree(objectTree) : nothing}
</li>
`;
};
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 onContextMenu = (
viewSource: boolean,
setViewSource: (value: boolean) => void,
decoding?: {decode: boolean, toggleDecode: () => void},
) => (event: Event): void => {
const contextMenu = new UI.ContextMenu.ContextMenu(event);
const section = contextMenu.newSection();
if (viewSource) {
section.appendItem(i18nString(UIStrings.viewParsed), () => setViewSource(!viewSource),
{jslogContext: 'view-parsed'});
} else {
section.appendItem(i18nString(UIStrings.viewSource), () => setViewSource(!viewSource),
{jslogContext: 'view-source'});
if (decoding) {
const viewURLEncodedText =
decoding.decode ? i18nString(UIStrings.viewUrlEncoded) : i18nString(UIStrings.viewDecoded);
section.appendItem(viewURLEncodedText, () => decoding.toggleDecode(), {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,
{
decode: input.decodeQueryParameters,
toggleDecode: () => input.setDecodeQueryParameters(!input.decodeQueryParameters),
}
)}
@expanded=${(e: UI.TreeOutline.TreeViewElement.ExpandEvent) =>
queryStringExpandedSetting.set(e.detail.expanded)}
?open=${queryStringExpandedSetting.get()}
>
<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=${(e: Event) => { e.consume(); input.setDecodeQueryParameters(!input.decodeQueryParameters); }}>
${input.decodeQueryParameters ? i18nString(UIStrings.viewUrlEncoded) : i18nString(UIStrings.viewDecoded)}
</devtools-button>
<ul role=group>
${ifExpanded(input.viewQueryParamSource ? createSourceText(input.queryString ?? '')
: createParsedParams(input.queryParameters ?? [], input.decodeQueryParameters))}
</ul>
</li>
<li
role=treeitem
?hidden=${!input.formData || !input.formParameters}
jslog=${VisualLogging.section().context('form-data')}
@contextmenu=${onContextMenu(
input.viewFormParamSource,
input.setViewFormParamSource,
{
decode: input.decodeFormParameters,
toggleDecode: () => input.setDecodeFormParameters(!input.decodeFormParameters),
}
)}
@expanded=${(e: UI.TreeOutline.TreeViewElement.ExpandEvent) => formDataExpandedSetting.set(e.detail.expanded)}
?open=${formDataExpandedSetting.get()}
>
<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=${(e: Event) => { e.consume(); input.setDecodeFormParameters(!input.decodeFormParameters); }}>
${input.decodeFormParameters ? i18nString(UIStrings.viewUrlEncoded) : i18nString(UIStrings.viewDecoded)}
</devtools-button>
<ul role=group>
${ifExpanded(input.viewFormParamSource ? createSourceText(input.formData ?? '')
: createParsedParams(input.formParameters ?? [], input.decodeFormParameters))}
</ul>
</li>
<li
role=treeitem
?hidden=${!input.formData || Boolean(input.formParameters) || Boolean(input.binaryPayloadContentData)}
jslog=${VisualLogging.section().context('request-payload')}
@contextmenu=${onContextMenu(
input.viewJSONPayloadSource,
input.setViewJSONPayloadSource,
)}
@expanded=${(e: UI.TreeOutline.TreeViewElement.ExpandEvent) =>
requestPayloadExpandedSetting.set(e.detail.expanded)}
?open=${requestPayloadExpandedSetting.get()}
>
<div class="selection fill"></div>${i18nString(UIStrings.requestPayload)}${
createViewSourceToggle(input.viewJSONPayloadSource, input.setViewJSONPayloadSource)}
<ul role=group>
${ifExpanded(!parsedFormData || input.viewJSONPayloadSource ? createSourceText(input.formData ?? '')
: createPayload(parsedFormData))}
</ul>
</li>
</ul>
`}></devtools-tree>
${input.binaryPayloadContentData ? html`
<div class="raw-payload-section"
jslog=${VisualLogging.section().context('binary-request-payload')}>
${widget(element => {
const streamingContent = TextUtils.StreamingContentData.StreamingContentData.from(
input.binaryPayloadContentData as TextUtils.ContentData.ContentData);
return new BinaryResourceView(
streamingContent,
input.requestUrl,
Common.ResourceType.resourceTypes.XHR,
element);
})}
</div>` : nothing}
`, target, {
container: {
classes: ['request-payload-view'],
attributes: {
jslog: `${VisualLogging.pane('payload').track({resize: true})}`,
},
}});
// clang-format on
};
export class RequestPayloadView extends UI.Widget.VBox {
#request?: SDK.NetworkRequest.NetworkRequest;
#decodeQueryParameters = true;
#decodeFormParameters = true;
#formData?: string;
#formParameters?: SDK.NetworkRequest.NameValue[];
#binaryPayloadContentData: TextUtils.ContentData.ContentData|null = null;
#view: View;
#viewJSONPayloadSource = false;
#viewFormParamSource = false;
#viewQueryParamSource = false;
#refreshFormDataPromiseForTest = Promise.resolve();
constructor(target?: HTMLElement, view = DEFAULT_VIEW) {
super();
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;
this.#decodeQueryParameters = true;
this.#decodeFormParameters = true;
const contentType = request.requestContentType();
if (contentType) {
this.#decodeFormParameters = 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();
this.#refreshFormData();
}
get request(): SDK.NetworkRequest.NetworkRequest|undefined {
return this.#request;
}
get refreshFormDataPromiseForTest(): Promise<void> {
return this.#refreshFormDataPromiseForTest;
}
override wasShown(): void {
super.wasShown();
this.request?.addEventListener(SDK.NetworkRequest.Events.REQUEST_HEADERS_CHANGED, this.#refreshFormData, this);
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,
decodeQueryParameters: this.#decodeQueryParameters,
setDecodeQueryParameters: (value: boolean): void => {
this.#decodeQueryParameters = value;
this.requestUpdate();
},
decodeFormParameters: this.#decodeFormParameters,
setDecodeFormParameters: (value: boolean): void => {
this.#decodeFormParameters = 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);
},
binaryPayloadContentData: this.#binaryPayloadContentData,
requestUrl: this.request?.url() ?? Platform.DevToolsPath.EmptyUrlString,
};
this.#view(input, {}, this.element);
}
#refreshFormData(): void {
this.#refreshFormDataPromiseForTest = this.#doRefreshFormData();
}
async #doRefreshFormData(): Promise<void> {
this.#formData = await this.request?.requestFormData() ?? undefined;
if (this.#formData) {
this.#formParameters = await this.request?.formParameters() ?? undefined;
}
// Fetch raw binary content data for the request body when the backend
// returns base64-encoded data. This enables the binary viewer (hex,
// base64, utf-8) in the Payload tab for compressed/binary bodies.
this.#binaryPayloadContentData = null;
if (this.request && !this.#formParameters) {
const contentData = await this.request.requestFormDataContentData();
if (!TextUtils.ContentData.ContentData.isError(contentData) && !contentData.isTextContent &&
contentData.createdFromBase64) {
this.#binaryPayloadContentData = contentData;
}
}
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>`;
}
}