chrome-devtools-frontend
Version:
Chrome DevTools UI
440 lines (394 loc) • 16.7 kB
text/typescript
// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable @devtools/no-imperative-dom-api */
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 Protocol from '../../generated/protocol.js';
import * as Buttons from '../../ui/components/buttons/buttons.js';
import * as UI from '../../ui/legacy/legacy.js';
import {Directives, html, render} from '../../ui/lit/lit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import xhrBreakpointsSidebarPaneStyles from './xhrBreakpointsSidebarPane.css.js';
const {classMap, ifDefined, ref} = Directives;
const UIStrings = {
/**
* @description Title of the 'XHR/fetch Breakpoints' tool in the bottom sidebar of the Sources tool
*/
xhrfetchBreakpoints: 'XHR/fetch Breakpoints',
/**
* @description Text to indicate there are no breakpoints
*/
noBreakpoints: 'No breakpoints',
/**
* @description Label for a button in the Sources panel that opens the input field to create a new XHR/fetch breakpoint.
*/
addXhrfetchBreakpoint: 'Add XHR/fetch breakpoint',
/**
* @description Text to add a breakpoint
*/
addBreakpoint: 'Add breakpoint',
/**
* @description Input element container text content in XHRBreakpoints Sidebar Pane of the JavaScript Debugging pane in the Sources panel or the DOM Breakpoints pane in the Elements panel
*/
breakWhenUrlContains: 'Break when URL contains:',
/**
* @description Accessible label for XHR/fetch breakpoint text input
*/
urlBreakpoint: 'URL Breakpoint',
/**
* @description Text in XHRBreakpoints Sidebar Pane of the JavaScript Debugging pane in the Sources panel or the DOM Breakpoints pane in the Elements panel
* @example {example.com} PH1
*/
urlContainsS: 'URL contains "{PH1}"',
/**
* @description Text in XHRBreakpoints Sidebar Pane of the JavaScript Debugging pane in the Sources panel or the DOM Breakpoints pane in the Elements panel
*/
anyXhrOrFetch: 'Any XHR or fetch',
/**
* @description Screen reader description of a hit breakpoint in the Sources panel
*/
breakpointHit: 'breakpoint hit',
/**
* @description Text to remove all breakpoints
*/
removeAllBreakpoints: 'Remove all breakpoints',
/**
* @description Text to remove a breakpoint
*/
removeBreakpoint: 'Remove breakpoint',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/browser_debugger/XHRBreakpointsSidebarPane.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const containerToBreakpointEntry = new WeakMap<Element, HTMLElement>();
let xhrBreakpointsSidebarPaneInstance: XHRBreakpointsSidebarPane|null = null;
export class XHRBreakpointsSidebarPane extends UI.Widget.VBox implements UI.ContextFlavorListener.ContextFlavorListener,
UI.Toolbar.ItemsProvider,
UI.ListControl.ListDelegate<string> {
readonly #breakpoints: UI.ListModel.ListModel<string>;
#list: UI.ListControl.ListControl<string>;
readonly #emptyElement: HTMLElement;
readonly #breakpointElements: Map<string, Element>;
readonly #addButton: UI.Toolbar.ToolbarButton;
#hitBreakpoint?: string;
#editingBreakpoint: string|null = null;
private constructor() {
super({
jslog: `${VisualLogging.section('source.xhr-breakpoints')}`,
useShadowDom: true,
});
this.registerRequiredCSS(xhrBreakpointsSidebarPaneStyles);
this.#breakpoints = new UI.ListModel.ListModel();
this.#list = new UI.ListControl.ListControl(this.#breakpoints, this, UI.ListControl.ListMode.NonViewport);
this.contentElement.appendChild(this.#list.element);
this.#list.element.classList.add('breakpoint-list', 'hidden');
UI.ARIAUtils.markAsList(this.#list.element);
UI.ARIAUtils.setLabel(this.#list.element, i18nString(UIStrings.xhrfetchBreakpoints));
this.#emptyElement = this.contentElement.createChild('div', 'gray-info-message');
this.#emptyElement.textContent = i18nString(UIStrings.noBreakpoints);
this.#breakpointElements = new Map();
this.#addButton = new UI.Toolbar.ToolbarButton(
i18nString(UIStrings.addXhrfetchBreakpoint), 'plus', undefined, 'sources.add-xhr-fetch-breakpoint');
this.#addButton.setSize(Buttons.Button.Size.SMALL);
this.#addButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, () => {
void this.addButtonClicked();
});
this.#emptyElement.addEventListener('contextmenu', this.emptyElementContextMenu.bind(this), true);
this.#emptyElement.tabIndex = -1;
this.restoreBreakpoints();
this.update();
}
static instance(): XHRBreakpointsSidebarPane {
if (!xhrBreakpointsSidebarPaneInstance) {
xhrBreakpointsSidebarPaneInstance = new XHRBreakpointsSidebarPane();
}
return xhrBreakpointsSidebarPaneInstance;
}
static removeInstance(): void {
xhrBreakpointsSidebarPaneInstance = null;
}
toolbarItems(): UI.Toolbar.ToolbarItem[] {
return [this.#addButton];
}
private emptyElementContextMenu(event: Event): void {
const contextMenu = new UI.ContextMenu.ContextMenu(event);
contextMenu.defaultSection().appendItem(
i18nString(UIStrings.addBreakpoint), this.addButtonClicked.bind(this),
{jslogContext: 'sources.add-xhr-fetch-breakpoint'});
void contextMenu.show();
}
private async addButtonClicked(): Promise<void> {
await UI.ViewManager.ViewManager.instance().showView('sources.xhr-breakpoints');
const inputElementContainer = document.createElement('p');
inputElementContainer.classList.add('breakpoint-condition');
inputElementContainer.setAttribute('jslog', `${VisualLogging.value('condition').track({change: true})}`);
this.addListElement(inputElementContainer, this.#list.element.firstChild as Element | null);
const commit = (e: UI.TextPrompt.TextPromptElement.CommitEvent): void => {
const newText = e.detail;
this.removeListElement(inputElementContainer);
SDK.DOMDebuggerModel.DOMDebuggerManager.instance().addXHRBreakpoint(newText, true);
this.setBreakpoint(newText);
this.update();
};
const cancel = (): void => {
this.removeListElement(inputElementContainer);
this.update();
};
// clang-format off
/* eslint-disable-next-line @devtools/no-lit-render-outside-of-view */
render(
html`
${i18nString(UIStrings.breakWhenUrlContains)}
<devtools-prompt
value=""
render-as-block
?editing=${true}
aria-label=${i18nString(UIStrings.urlBreakpoint)}
class="breakpoint-condition-input"
=${commit}
=${cancel}>
</devtools-prompt>
`,
inputElementContainer
);
// clang-format on
}
heightForItem(_item: string): number {
return 0;
}
isItemSelectable(_item: string): boolean {
return true;
}
private setBreakpoint(breakKeyword: string): void {
if (this.#breakpoints.indexOf(breakKeyword) !== -1) {
this.#list.refreshItem(breakKeyword);
} else {
this.#breakpoints.insertWithComparator(breakKeyword, Platform.ArrayUtilities.DEFAULT_COMPARATOR);
}
if (!this.#list.selectedItem() || !this.hasFocus()) {
this.#list.selectItem(this.#breakpoints.at(0));
}
}
createElementForItem(item: string): Element {
const listItemElement = document.createElement('div');
UI.ARIAUtils.markAsListitem(listItemElement);
const enabled = SDK.DOMDebuggerModel.DOMDebuggerManager.instance().xhrBreakpoints().get(item) || false;
const title = item ? i18nString(UIStrings.urlContainsS, {PH1: item}) : i18nString(UIStrings.anyXhrOrFetch);
const commit = (e: UI.TextPrompt.TextPromptElement.CommitEvent): void => {
if (this.#editingBreakpoint !== item) {
return;
}
const newText = e.detail;
this.#editingBreakpoint = null;
this.#removeBreakpoint(item);
this.#addBreakpoint(newText, enabled);
this.#list.selectItem(newText);
this.focus();
};
const cancel = (): void => {
if (this.#editingBreakpoint !== item) {
return;
}
this.#editingBreakpoint = null;
this.#list.refreshItem(item);
this.focus();
};
// clang-format off
/* eslint-disable-next-line @devtools/no-lit-render-outside-of-view */
render(
html`
<div class=${classMap({'breakpoint-entry': true, 'breakpoint-hit': item === this.#hitBreakpoint})}
role="checkbox"
aria-checked=${enabled ? 'true' : 'false'}
aria-label=${title}
aria-description=${ifDefined(item === this.#hitBreakpoint ? i18nString(UIStrings.breakpointHit) : undefined)}
tabindex=${item === this.#list.selectedItem() ? '0' : '-1'}
?autofocus=${item === this.#list.selectedItem()}
${ref((el?: Element) => {
if (el instanceof HTMLElement) {
containerToBreakpointEntry.set(listItemElement, el);
this.#breakpointElements.set(item, listItemElement);
}
})}
=${(event: Event) => {
if (event.target === event.currentTarget) {
this.checkboxClicked(item, enabled);
}
}}
=${(e: Event) => this.contextMenu(item, e)}
=${(event: KeyboardEvent) => {
let handled = false;
if (event.key === ' ') {
this.checkboxClicked(item, enabled);
handled = true;
} else if (event.key === 'Enter') {
this.#startEditing(item);
handled = true;
}
if (handled) {
event.consume(true);
}
}}>
<devtools-checkbox
class="cursor-auto"
aria-hidden="true"
.checked=${enabled}
.small=${true}
.title=${title}
=${(e: Event) => e.stopPropagation()}
=${() => this.checkboxClicked(item, enabled)}
=${() => this.#startEditing(item)}
tabindex="-1"
jslog=${VisualLogging.toggle().track({click: true})}>
<devtools-prompt
value=${item}
render-as-block
?editing=${item === this.#editingBreakpoint}
aria-label=${title}
class=${classMap({'breakpoint-condition-input': item === this.#editingBreakpoint})}
jslog=${VisualLogging.value('condition').track({change: true})}
=${commit}
=${cancel}>
${title}
</devtools-prompt>
</devtools-checkbox>
</div>
`,
listItemElement
);
// clang-format on
listItemElement.setAttribute('jslog', `${VisualLogging.item().track({
click: true,
dblclick: true,
resize: true,
keydown: 'ArrowUp|ArrowDown|PageUp|PageDown|Enter|Space',
})}`);
return listItemElement;
}
selectedItemChanged(_from: string|null, _to: string|null, fromElement: HTMLElement|null, toElement: HTMLElement|null):
void {
if (fromElement) {
const breakpointEntryElement = containerToBreakpointEntry.get(fromElement);
if (!breakpointEntryElement) {
throw new Error('Expected breakpoint entry to be found for an element');
}
breakpointEntryElement.tabIndex = -1;
}
if (toElement) {
const breakpointEntryElement = containerToBreakpointEntry.get(toElement);
if (!breakpointEntryElement) {
throw new Error('Expected breakpoint entry to be found for an element');
}
const prompt =
_to === this.#editingBreakpoint ? toElement.querySelector('devtools-prompt') as HTMLElement | null : null;
this.setDefaultFocusedElement(prompt || breakpointEntryElement);
breakpointEntryElement.tabIndex = 0;
if (this.hasFocus()) {
if (prompt) {
prompt.focus();
return;
}
breakpointEntryElement.focus();
}
}
}
updateSelectedItemARIA(_fromElement: Element|null, _toElement: Element|null): boolean {
return true;
}
private removeBreakpoint(breakKeyword: string): void {
const index = this.#breakpoints.indexOf(breakKeyword);
if (index >= 0) {
this.#breakpoints.remove(index);
}
this.#breakpointElements.delete(breakKeyword);
this.update();
}
private addListElement(element: Element, beforeNode: Node|null): void {
this.#list.element.insertBefore(element, beforeNode);
this.#emptyElement.classList.add('hidden');
this.#list.element.classList.remove('hidden');
}
private removeListElement(element: Element): void {
this.#list.element.removeChild(element);
if (!this.#list.element.firstElementChild) {
this.#emptyElement.classList.remove('hidden');
this.#list.element.classList.add('hidden');
}
}
#addBreakpoint(url: string, enabled = true): void {
SDK.DOMDebuggerModel.DOMDebuggerManager.instance().addXHRBreakpoint(url, enabled);
this.setBreakpoint(url);
}
#removeBreakpoint(url: string): void {
SDK.DOMDebuggerModel.DOMDebuggerManager.instance().removeXHRBreakpoint(url);
this.removeBreakpoint(url);
}
#removeAllBreakpoints(): void {
for (const url of this.#breakpointElements.keys()) {
this.#removeBreakpoint(url);
}
this.update();
}
#toggleBreakpoint(url: string, checked: boolean): void {
SDK.DOMDebuggerModel.DOMDebuggerManager.instance().toggleXHRBreakpoint(url, checked);
this.#list.refreshItem(url);
this.#list.selectItem(url);
}
private contextMenu(breakKeyword: string, event: Event): void {
const contextMenu = new UI.ContextMenu.ContextMenu(event);
const removeAllTitle = i18nString(UIStrings.removeAllBreakpoints);
contextMenu.defaultSection().appendItem(
i18nString(UIStrings.addBreakpoint), this.addButtonClicked.bind(this),
{jslogContext: 'sources.add-xhr-fetch-breakpoint'});
contextMenu.defaultSection().appendItem(i18nString(UIStrings.removeBreakpoint),
this.#removeBreakpoint.bind(this, breakKeyword),
{jslogContext: 'sources.remove-xhr-fetch-breakpoint'});
contextMenu.defaultSection().appendItem(removeAllTitle, this.#removeAllBreakpoints.bind(this),
{jslogContext: 'sources.remove-all-xhr-fetch-breakpoints'});
void contextMenu.show();
}
private checkboxClicked(breakKeyword: string, checked: boolean): void {
const hadFocus = this.hasFocus();
this.#toggleBreakpoint(breakKeyword, !checked);
if (hadFocus) {
this.focus();
}
}
#startEditing(item: string): void {
this.#editingBreakpoint = item;
this.#list.refreshItem(item);
}
flavorChanged(_object: Object|null): void {
this.update();
}
update(): void {
const isEmpty = this.#breakpoints.length === 0;
this.#list.element.classList.toggle('hidden', isEmpty);
this.#emptyElement.classList.toggle('hidden', !isEmpty);
const details = UI.Context.Context.instance().flavor(SDK.DebuggerModel.DebuggerPausedDetails);
if (!details || details.reason !== Protocol.Debugger.PausedEventReason.XHR) {
if (this.#hitBreakpoint) {
const oldHitBreakpoint = this.#hitBreakpoint;
this.#hitBreakpoint = undefined;
if (this.#breakpoints.indexOf(oldHitBreakpoint) >= 0) {
this.#list.refreshItem(oldHitBreakpoint);
}
}
return;
}
const url = details.auxData?.['breakpointURL'];
this.#hitBreakpoint = url;
if (this.#breakpoints.indexOf(url) < 0) {
return;
}
this.#list.refreshItem(url);
void UI.ViewManager.ViewManager.instance().showView('sources.xhr-breakpoints');
}
private restoreBreakpoints(): void {
const breakpoints = SDK.DOMDebuggerModel.DOMDebuggerManager.instance().xhrBreakpoints();
for (const url of breakpoints.keys()) {
this.setBreakpoint(url);
}
}
}