| // Copyright 2026 The Chromium Authors | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import '../../ui/legacy/legacy.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 type * as TextUtils from '../../core/text_utils/text_utils.js'; | |
| import type * as Protocol from '../../generated/protocol.js'; | |
| import * as Buttons from '../../ui/components/buttons/buttons.js'; | |
| import * as UI from '../../ui/legacy/legacy.js'; | |
| import {html, render} from '../../ui/lit/lit.js'; | |
| import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; | |
| import * as ApplicationComponents from './components/components.js'; | |
| const UIStrings = { | |
| /** | |
| * @description Header text in an empty state view when no context entries are detected in the crash report context view of the Application panel. | |
| */ | |
| noContext: 'No context entries detected across frames', | |
| /** | |
| * @description Fallback label when a frame has no URL in the crash report context view of the Application panel. | |
| */ | |
| unknownFrame: 'Unknown frame', | |
| /** | |
| * @description Placeholder text for the filter input in the crash report context view of the Application panel. | |
| */ | |
| filterByText: 'Filter by key or value', | |
| /** | |
| * @description Tooltip text for the refresh button in the toolbar of the crash report context view in the Application panel. | |
| */ | |
| refresh: 'Refresh', | |
| } as const; | |
| const str_ = i18n.i18n.registerUIStrings('panels/application/CrashReportContextView.ts', UIStrings); | |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); | |
| export interface FrameContextData { | |
| url: string; | |
| frameId: string; | |
| displayName: string; | |
| entries: Protocol.CrashReportContext.CrashReportContextEntry[]; | |
| } | |
| interface ViewInput { | |
| frames: FrameContextData[]; | |
| selectedKey: string|null; | |
| onRowSelected: (key: string) => void; | |
| onRefresh: () => void; | |
| onFilterChanged: (e: CustomEvent<string>) => void; | |
| filters: TextUtils.TextUtils.ParsedFilter[]; | |
| } | |
| type View = (input: ViewInput, output: undefined, target: HTMLElement) => void; | |
| export const DEFAULT_VIEW = (input: ViewInput, _output: undefined, target: HTMLElement): void => { | |
| const {widget} = UI.Widget; | |
| // clang-format off | |
| render( | |
| html` | |
| <style>${UI.inspectorCommonStyles}</style> | |
| <style> | |
| .crash-report-context-view { | |
| padding-top: 5px; | |
| overflow: auto; | |
| } | |
| .frame-section { | |
| margin-top: var(--sys-size-8); | |
| } | |
| .frame-section:first-child { | |
| margin-top: 0; | |
| } | |
| .frame-header { | |
| display: flex; | |
| align-items: center; | |
| padding: var(--sys-size-4) var(--sys-size-6); | |
| gap: var(--sys-size-6); | |
| background-color: var(--sys-color-surface2); | |
| border-bottom: 1px solid var(--sys-color-divider); | |
| } | |
| .frame-url { | |
| font-weight: var(--ref-typeface-weight-bold); | |
| flex: 1; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| font-family: var(--default-font-family); | |
| } | |
| .toolbar-container { | |
| border-bottom: 1px solid var(--sys-color-divider); | |
| background-color: var(--sys-color-cdt-base-container); | |
| } | |
| </style> | |
| <div class="vbox flex-auto" jslog=${VisualLogging.pane('crash-report-context')}> | |
| <devtools-toolbar class="crash-report-context-toolbar" role="toolbar" jslog=${VisualLogging.toolbar()}> | |
| <devtools-button title=${i18nString(UIStrings.refresh)} | |
| @click=${input.onRefresh} | |
| .iconName=${'refresh'} | |
| .variant=${Buttons.Button.Variant.TOOLBAR} | |
| jslog=${VisualLogging.action('refresh').track({ | |
| click: true, | |
| })}> | |
| </devtools-button> | |
| <devtools-toolbar-input type="filter" placeholder=${i18nString(UIStrings.filterByText)} | |
| @change=${(e: CustomEvent<string>) => input.onFilterChanged(e)} class="flex-auto"> | |
| </devtools-toolbar-input> | |
| </devtools-toolbar> | |
| ${input.frames.length > 0 ? html` | |
| <div class="crash-report-context-view flex-auto"> | |
| ${input.frames.map(frame => html` | |
| <div class="frame-section"> | |
| <div class="frame-header"> | |
| <span class="frame-url" title="URL: ${frame.url}\nFrame ID: ${frame.frameId}">${frame.displayName}</span> | |
| </div> | |
| <div class="grid-container"> | |
| <devtools-widget | |
| ${widget(ApplicationComponents.CrashReportContextGrid.CrashReportContextGrid, { | |
| data: { | |
| entries: frame.entries.map(e => ({key: e.key, value: e.value})), | |
| selectedKey: input.selectedKey || undefined, | |
| filters: input.filters, | |
| }, | |
| })} | |
| @select=${(e: CustomEvent<string>) => input.onRowSelected(e.detail)}> | |
| </devtools-widget> | |
| </div> | |
| </div> | |
| `)} | |
| </div> | |
| ` : html` | |
| ${widget(UI.EmptyWidget.EmptyWidget, { | |
| header: i18nString(UIStrings.noContext), | |
| })} | |
| `} | |
| </div> | |
| `, | |
| target); | |
| // clang-format on | |
| }; | |
| export class CrashReportContextView extends UI.Widget.VBox { | |
| private selectedKey: string|null = null; | |
| readonly #view: View; | |
| #filters: TextUtils.TextUtils.ParsedFilter[] = []; | |
| constructor(view: View = DEFAULT_VIEW) { | |
| super(); | |
| this.#view = view; | |
| this.requestUpdate(); | |
| } | |
| override async performUpdate(): Promise<void> { | |
| const models = | |
| SDK.TargetManager.TargetManager.instance().models(SDK.CrashReportContextModel.CrashReportContextModel); | |
| const allEntries = | |
| (await Promise.all(models.map(model => model.getEntries()))) | |
| .flat() | |
| .filter((entry): entry is Protocol.CrashReportContext.CrashReportContextEntry => entry !== null); | |
| const frameData = this.#processFrameData(allEntries); | |
| this.#view( | |
| { | |
| frames: frameData, | |
| selectedKey: this.selectedKey, | |
| filters: this.#filters, | |
| onRowSelected: (key: string) => { | |
| this.selectedKey = key; | |
| this.requestUpdate(); | |
| }, | |
| onRefresh: () => { | |
| this.requestUpdate(); | |
| }, | |
| onFilterChanged: (e: CustomEvent<string>) => { | |
| const text = e.detail; | |
| const textFilterRegExp = text ? Platform.StringUtilities.createPlainTextSearchRegex(text, 'i') : null; | |
| if (textFilterRegExp) { | |
| this.#filters = [ | |
| {key: 'key,value', regex: textFilterRegExp, negative: false}, | |
| ]; | |
| } else { | |
| this.#filters = []; | |
| } | |
| this.requestUpdate(); | |
| }, | |
| }, | |
| undefined, this.contentElement); | |
| } | |
| #processFrameData(allEntries: Protocol.CrashReportContext.CrashReportContextEntry[]): FrameContextData[] { | |
| if (allEntries.length === 0) { | |
| return []; | |
| } | |
| const entriesByFrame = Map.groupBy(allEntries, entry => entry.frameId); | |
| return [...entriesByFrame.entries()] | |
| .map(([frameId, frameEntries]) => { | |
| const frame = SDK.FrameManager.FrameManager.instance().getFrame(frameId as Protocol.Page.FrameId); | |
| const url = frame?.url || i18nString(UIStrings.unknownFrame); | |
| const displayName = frame?.displayName() || url; | |
| return { | |
| url, | |
| frameId, | |
| displayName, | |
| isMain: frame?.isMainFrame() ?? false, | |
| entries: frameEntries, | |
| }; | |
| }) | |
| // Ensure the main (outermost) frame is always listed first at the top of the View | |
| .sort((a, b) => { | |
| if (a.isMain && !b.isMain) { | |
| return -1; | |
| } | |
| if (!a.isMain && b.isMain) { | |
| return 1; | |
| } | |
| return 0; | |
| }) | |
| .map(data => ({ | |
| url: data.url, | |
| frameId: data.frameId, | |
| displayName: data.displayName, | |
| entries: data.entries, | |
| })); | |
| } | |
| } |