chrome-devtools-frontend
Version:
Chrome DevTools UI
482 lines (434 loc) • 18.5 kB
text/typescript
// Copyright 2022 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.
/* eslint-disable rulesdir/no-imperative-dom-api */
/* eslint-disable rulesdir/no-lit-render-outside-of-view */
import * as Host from '../../../core/host/host.js';
import * as i18n from '../../../core/i18n/i18n.js';
import type * as Protocol from '../../../generated/protocol.js';
import * as Persistence from '../../../models/persistence/persistence.js';
import * as Workspace from '../../../models/workspace/workspace.js';
import * as Buttons from '../../../ui/components/buttons/buttons.js';
import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
import * as UI from '../../../ui/legacy/legacy.js';
import * as Lit from '../../../ui/lit/lit.js';
import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js';
import headersViewStyles from './HeadersView.css.js';
const {html} = Lit;
const UIStrings = {
/**
*@description The title of a button that adds a field to input a header in the editor form.
*/
addHeader: 'Add a header',
/**
*@description The title of a button that removes a field to input a header in the editor form.
*/
removeHeader: 'Remove this header',
/**
*@description The title of a button that removes a section for defining header overrides in the editor form.
*/
removeBlock: 'Remove this \'`ApplyTo`\'-section',
/**
*@description Error message for files which cannot not be parsed.
*@example {.headers} PH1
*/
errorWhenParsing: 'Error when parsing \'\'{PH1}\'\'.',
/**
*@description Explainer for files which cannot be parsed.
*@example {.headers} PH1
*/
parsingErrorExplainer:
'This is most likely due to a syntax error in \'\'{PH1}\'\'. Try opening this file in an external editor to fix the error or delete the file and re-create the override.',
/**
*@description Button text for a button which adds an additional header override rule.
*/
addOverrideRule: 'Add override rule',
/**
*@description Text which is a hyperlink to more documentation
*/
learnMore: 'Learn more',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/sources/components/HeadersView.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const DEFAULT_HEADER_VALUE = 'header value';
const getDefaultHeaderName = (i: number): string => `header-name-${i}`;
export class HeadersView extends UI.View.SimpleView {
readonly #headersViewComponent = new HeadersViewComponent();
#uiSourceCode: Workspace.UISourceCode.UISourceCode;
constructor(uiSourceCode: Workspace.UISourceCode.UISourceCode) {
super(i18n.i18n.lockedString('HeadersView'));
this.element.setAttribute('jslog', `${VisualLogging.pane('headers-view')}`);
this.#uiSourceCode = uiSourceCode;
this.#uiSourceCode.addEventListener(
Workspace.UISourceCode.Events.WorkingCopyChanged, this.#onWorkingCopyChanged, this);
this.#uiSourceCode.addEventListener(
Workspace.UISourceCode.Events.WorkingCopyCommitted, this.#onWorkingCopyCommitted, this);
this.element.appendChild(this.#headersViewComponent);
void this.#setInitialData();
}
async #setInitialData(): Promise<void> {
const content = await this.#uiSourceCode.requestContent();
this.#setComponentData(content.content || '');
}
#setComponentData(content: string): void {
let parsingError = false;
let headerOverrides: Persistence.NetworkPersistenceManager.HeaderOverride[] = [];
content = content || '[]';
try {
headerOverrides = JSON.parse(content) as Persistence.NetworkPersistenceManager.HeaderOverride[];
if (!headerOverrides.every(Persistence.NetworkPersistenceManager.isHeaderOverride)) {
throw new Error('Type mismatch after parsing');
}
} catch {
console.error('Failed to parse', this.#uiSourceCode.url(), 'for locally overriding headers.');
parsingError = true;
}
this.#headersViewComponent.data = {
headerOverrides,
uiSourceCode: this.#uiSourceCode,
parsingError,
};
}
#onWorkingCopyChanged(): void {
this.#setComponentData(this.#uiSourceCode.workingCopy());
}
#onWorkingCopyCommitted(): void {
this.#setComponentData(this.#uiSourceCode.workingCopy());
}
getComponent(): HeadersViewComponent {
return this.#headersViewComponent;
}
dispose(): void {
this.#uiSourceCode.removeEventListener(
Workspace.UISourceCode.Events.WorkingCopyChanged, this.#onWorkingCopyChanged, this);
this.#uiSourceCode.removeEventListener(
Workspace.UISourceCode.Events.WorkingCopyCommitted, this.#onWorkingCopyCommitted, this);
}
}
export interface HeadersViewComponentData {
headerOverrides: Persistence.NetworkPersistenceManager.HeaderOverride[];
uiSourceCode: Workspace.UISourceCode.UISourceCode;
parsingError: boolean;
}
export class HeadersViewComponent extends HTMLElement {
readonly #shadow = this.attachShadow({mode: 'open'});
#headerOverrides: Persistence.NetworkPersistenceManager.HeaderOverride[] = [];
#uiSourceCode: Workspace.UISourceCode.UISourceCode|null = null;
#parsingError = false;
#focusElement: {blockIndex: number, headerIndex?: number}|null = null;
#textOnFocusIn = '';
constructor() {
super();
this.#shadow.addEventListener('focusin', this.#onFocusIn.bind(this));
this.#shadow.addEventListener('focusout', this.#onFocusOut.bind(this));
this.#shadow.addEventListener('click', this.#onClick.bind(this));
this.#shadow.addEventListener('input', this.#onInput.bind(this));
this.#shadow.addEventListener('keydown', this.#onKeyDown.bind(this));
this.#shadow.addEventListener('paste', this.#onPaste.bind(this));
this.addEventListener('contextmenu', this.#onContextMenu.bind(this));
}
set data(data: HeadersViewComponentData) {
this.#headerOverrides = data.headerOverrides;
this.#uiSourceCode = data.uiSourceCode;
this.#parsingError = data.parsingError;
void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render);
}
// 'Enter' key should not create a new line in the contenteditable. Focus
// on the next contenteditable instead.
#onKeyDown(event: Event): void {
const target = event.target as HTMLElement;
if (!target.matches('.editable')) {
return;
}
const keyboardEvent = event as KeyboardEvent;
if (target.matches('.header-name') && target.innerText === '' &&
(keyboardEvent.key === 'Enter' || keyboardEvent.key === 'Tab')) {
// onFocusOut will remove the header -> blur instead of focusing on next editable
event.preventDefault();
target.blur();
} else if (keyboardEvent.key === 'Enter') {
event.preventDefault();
target.blur();
this.#focusNext(target);
} else if (keyboardEvent.key === 'Escape') {
event.consume();
target.innerText = this.#textOnFocusIn;
target.blur();
this.#onChange(target);
}
}
#focusNext(target: HTMLElement): void {
const elements = Array.from(this.#shadow.querySelectorAll('.editable')) as HTMLElement[];
const idx = elements.indexOf(target);
if (idx !== -1 && idx + 1 < elements.length) {
elements[idx + 1].focus();
}
}
#selectAllText(target: HTMLElement): void {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(target);
selection?.removeAllRanges();
selection?.addRange(range);
}
#onFocusIn(event: Event): void {
const target = event.target as HTMLElement;
if (target.matches('.editable')) {
this.#selectAllText(target);
this.#textOnFocusIn = target.innerText;
}
}
#onFocusOut(event: Event): void {
const target = event.target as HTMLElement;
if (target.innerText === '') {
const rowElement = target.closest('.row') as HTMLElement;
const blockIndex = Number(rowElement.dataset.blockIndex);
const headerIndex = Number(rowElement.dataset.headerIndex);
if (target.matches('.apply-to')) {
target.innerText = '*';
this.#headerOverrides[blockIndex].applyTo = '*';
this.#onHeadersChanged();
} else if (target.matches('.header-name')) {
this.#removeHeader(blockIndex, headerIndex);
}
}
// clear selection
const selection = window.getSelection();
selection?.removeAllRanges();
this.#uiSourceCode?.commitWorkingCopy();
}
#onContextMenu(event: Event): void {
if (!this.#uiSourceCode) {
return;
}
const contextMenu = new UI.ContextMenu.ContextMenu(event);
contextMenu.appendApplicableItems(this.#uiSourceCode);
void contextMenu.show();
}
#generateNextHeaderName(headers: Protocol.Fetch.HeaderEntry[]): string {
const takenNames = new Set<string>(headers.map(header => header.name));
let idx = 1;
while (takenNames.has(getDefaultHeaderName(idx))) {
idx++;
}
return getDefaultHeaderName(idx);
}
#onClick(event: Event): void {
const target = event.target as HTMLButtonElement;
const rowElement = target.closest('.row') as HTMLElement | null;
const blockIndex = Number(rowElement?.dataset.blockIndex || 0);
const headerIndex = Number(rowElement?.dataset.headerIndex || 0);
if (target.matches('.add-header')) {
this.#headerOverrides[blockIndex].headers.splice(
headerIndex + 1, 0,
{name: this.#generateNextHeaderName(this.#headerOverrides[blockIndex].headers), value: DEFAULT_HEADER_VALUE});
this.#focusElement = {blockIndex, headerIndex: headerIndex + 1};
this.#onHeadersChanged();
} else if (target.matches('.remove-header')) {
this.#removeHeader(blockIndex, headerIndex);
} else if (target.matches('.add-block')) {
this.#headerOverrides.push(
{applyTo: '*', headers: [{name: getDefaultHeaderName(1), value: DEFAULT_HEADER_VALUE}]});
this.#focusElement = {blockIndex: this.#headerOverrides.length - 1};
this.#onHeadersChanged();
} else if (target.matches('.remove-block')) {
this.#headerOverrides.splice(blockIndex, 1);
this.#onHeadersChanged();
}
}
#isDeletable(blockIndex: number, headerIndex: number): boolean {
const isOnlyDefaultHeader = headerIndex === 0 && this.#headerOverrides[blockIndex].headers.length === 1 &&
this.#headerOverrides[blockIndex].headers[headerIndex].name === getDefaultHeaderName(1) &&
this.#headerOverrides[blockIndex].headers[headerIndex].value === DEFAULT_HEADER_VALUE;
return !isOnlyDefaultHeader;
}
#removeHeader(blockIndex: number, headerIndex: number): void {
this.#headerOverrides[blockIndex].headers.splice(headerIndex, 1);
if (this.#headerOverrides[blockIndex].headers.length === 0) {
this.#headerOverrides[blockIndex].headers.push(
{name: this.#generateNextHeaderName(this.#headerOverrides[blockIndex].headers), value: DEFAULT_HEADER_VALUE});
}
this.#onHeadersChanged();
}
#onInput(event: Event): void {
this.#onChange(event.target as HTMLElement);
}
#onChange(target: HTMLElement): void {
const rowElement = target.closest('.row') as HTMLElement;
const blockIndex = Number(rowElement.dataset.blockIndex);
const headerIndex = Number(rowElement.dataset.headerIndex);
if (target.matches('.header-name')) {
this.#headerOverrides[blockIndex].headers[headerIndex].name = target.innerText;
this.#onHeadersChanged();
}
if (target.matches('.header-value')) {
this.#headerOverrides[blockIndex].headers[headerIndex].value = target.innerText;
this.#onHeadersChanged();
}
if (target.matches('.apply-to')) {
this.#headerOverrides[blockIndex].applyTo = target.innerText;
this.#onHeadersChanged();
}
}
#onHeadersChanged(): void {
this.#uiSourceCode?.setWorkingCopy(JSON.stringify(this.#headerOverrides, null, 2));
Host.userMetrics.actionTaken(Host.UserMetrics.Action.HeaderOverrideHeadersFileEdited);
}
#onPaste(event: Event): void {
const clipboardEvent = event as ClipboardEvent;
event.preventDefault();
if (clipboardEvent.clipboardData) {
const text = clipboardEvent.clipboardData.getData('text/plain');
const range = this.#shadow.getSelection()?.getRangeAt(0);
if (!range) {
return;
}
range.deleteContents();
const textNode = document.createTextNode(text);
range.insertNode(textNode);
range.selectNodeContents(textNode);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
this.#onChange(event.target as HTMLElement);
}
}
#render(): void {
if (!ComponentHelpers.ScheduledRender.isScheduledRender(this)) {
throw new Error('HeadersView render was not scheduled');
}
if (this.#parsingError) {
const fileName = this.#uiSourceCode?.name() || '.headers';
// clang-format off
Lit.render(html`
<style>${headersViewStyles}</style>
<div class="center-wrapper">
<div class="centered">
<div class="error-header">${i18nString(UIStrings.errorWhenParsing, {PH1: fileName})}</div>
<div class="error-body">${i18nString(UIStrings.parsingErrorExplainer, {PH1: fileName})}</div>
</div>
</div>
`, this.#shadow, {host: this});
// clang-format on
return;
}
// clang-format off
Lit.render(html`
<style>${headersViewStyles}</style>
${this.#headerOverrides.map((headerOverride, blockIndex) =>
html`
${this.#renderApplyToRow(headerOverride.applyTo, blockIndex)}
${headerOverride.headers.map((header, headerIndex) =>
html`
${this.#renderHeaderRow(header, blockIndex, headerIndex)}
`,
)}
`,
)}
<devtools-button
.variant=${Buttons.Button.Variant.OUTLINED}
.jslogContext=${'headers-view.add-override-rule'}
class="add-block">
${i18nString(UIStrings.addOverrideRule)}
</devtools-button>
<div class="learn-more-row">
<x-link
href="https://goo.gle/devtools-override"
class="link"
jslog=${VisualLogging.link('learn-more').track({click: true})}>${i18nString(UIStrings.learnMore)}</x-link>
</div>
`, this.#shadow, {host: this});
// clang-format on
if (this.#focusElement) {
let focusElement: Element|null = null;
if (this.#focusElement.headerIndex) {
focusElement = this.#shadow.querySelector(`[data-block-index="${
this.#focusElement.blockIndex}"][data-header-index="${this.#focusElement.headerIndex}"] .header-name`);
} else {
focusElement = this.#shadow.querySelector(`[data-block-index="${this.#focusElement.blockIndex}"] .apply-to`);
}
if (focusElement) {
(focusElement as HTMLElement).focus();
}
this.#focusElement = null;
}
}
#renderApplyToRow(pattern: string, blockIndex: number): Lit.TemplateResult {
// clang-format off
return html`
<div class="row" data-block-index=${blockIndex}
jslog=${VisualLogging.treeItem(pattern === '*' ? pattern : undefined)}>
<div>${i18n.i18n.lockedString('Apply to')}</div>
<div class="separator">:</div>
${this.#renderEditable(pattern, 'apply-to')}
<devtools-button
title=${i18nString(UIStrings.removeBlock)}
.size=${Buttons.Button.Size.SMALL}
.iconName=${'bin'}
.iconWidth=${'14px'}
.iconHeight=${'14px'}
.variant=${Buttons.Button.Variant.ICON}
.jslogContext=${'headers-view.remove-apply-to-section'}
class="remove-block inline-button"
></devtools-button>
</div>
`;
// clang-format on
}
#renderHeaderRow(header: Protocol.Fetch.HeaderEntry, blockIndex: number, headerIndex: number): Lit.TemplateResult {
// clang-format off
return html`
<div class="row padded" data-block-index=${blockIndex} data-header-index=${headerIndex}
jslog=${VisualLogging.treeItem(header.name).parent('headers-editor-row-parent')}>
${this.#renderEditable(header.name, 'header-name red', true)}
<div class="separator">:</div>
${this.#renderEditable(header.value, 'header-value')}
<devtools-button
title=${i18nString(UIStrings.addHeader)}
.size=${Buttons.Button.Size.SMALL}
.iconName=${'plus'}
.variant=${Buttons.Button.Variant.ICON}
.jslogContext=${'headers-view.add-header'}
class="add-header inline-button"
></devtools-button>
<devtools-button
title=${i18nString(UIStrings.removeHeader)}
.size=${Buttons.Button.Size.SMALL}
.iconName=${'bin'}
.variant=${Buttons.Button.Variant.ICON}
?hidden=${!this.#isDeletable(blockIndex, headerIndex)}
.jslogContext=${'headers-view.remove-header'}
class="remove-header inline-button"
></devtools-button>
</div>
`;
// clang-format on
}
#renderEditable(value: string, className?: string, isKey?: boolean): Lit.TemplateResult {
// This uses Lit's `live`-directive, so that when checking whether to
// update during re-render, `value` is compared against the actual live DOM
// value of the contenteditable element and not the potentially outdated
// value from the previous render.
// clang-format off
const jslog = isKey ? VisualLogging.key() : VisualLogging.value();
return html`<span jslog=${jslog.track({change: true, keydown: 'Enter|Escape|Tab', click: true})}
contenteditable="true"
class="editable ${className}"
tabindex="0"
.innerText=${Lit.Directives.live(value)}></span>`;
// clang-format on
}
}
VisualLogging.registerParentProvider('headers-editor-row-parent', (e: Element) => {
while (e.previousElementSibling?.classList?.contains('padded')) {
e = e.previousElementSibling;
}
return e.previousElementSibling || undefined;
});
customElements.define('devtools-sources-headers-view', HeadersViewComponent);
declare global {
interface HTMLElementTagNameMap {
'devtools-sources-headers-view': HeadersViewComponent;
}
}