chrome-devtools-frontend
Version:
Chrome DevTools UI
1,275 lines (1,156 loc) • 52.7 kB
text/typescript
// Copyright 2015 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 */
import '../../ui/legacy/legacy.js';
import * as Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as Trace from '../../models/trace/trace.js';
import * as Buttons from '../../ui/components/buttons/buttons.js';
import * as DataGrid from '../../ui/legacy/components/data_grid/data_grid.js';
import * as Components from '../../ui/legacy/components/utils/utils.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import {ActiveFilters} from './ActiveFilters.js';
import * as Extensions from './extensions/extensions.js';
import {Tracker} from './FreshRecording.js';
import {targetForEvent} from './TargetForEvent.js';
import * as ThirdPartyTreeView from './ThirdPartyTreeView.js';
import {TimelineRegExp} from './TimelineFilters.js';
import {rangeForSelection, type TimelineSelection} from './TimelineSelection.js';
import timelineTreeViewStyles from './timelineTreeView.css.js';
import {TimelineUIUtils} from './TimelineUIUtils.js';
import * as Utils from './utils/utils.js';
const UIStrings = {
/**
*@description Text for the performance of something
*/
performance: 'Performance',
/**
*@description Time of a single activity, as opposed to the total time
*/
selfTime: 'Self time',
/**
*@description Text for the total time of something
*/
totalTime: 'Total time',
/**
*@description Text in Timeline Tree View of the Performance panel
*/
activity: 'Activity',
/**
*@description Text of a DOM element in Timeline Tree View of the Performance panel
*/
selectItemForDetails: 'Select item for details.',
/**
*@description Number followed by percent sign
*@example {20} PH1
*/
percentPlaceholder: '{PH1} %',
/**
*@description Text in Timeline Tree View of the Performance panel
*/
chromeExtensionsOverhead: '[`Chrome` extensions overhead]',
/**
* @description Text in Timeline Tree View of the Performance panel. The text is presented
* when developers investigate the performance of a page. 'V8 Runtime' labels the time
* spent in (i.e. runtime) the V8 JavaScript engine.
*/
vRuntime: '[`V8` Runtime]',
/**
*@description Text in Timeline Tree View of the Performance panel
*/
unattributed: '[unattributed]',
/**
*@description Text that refers to one or a group of webpages
*/
page: 'Page',
/**
*@description Text in Timeline Tree View of the Performance panel
*/
noGrouping: 'No grouping',
/**
*@description Text in Timeline Tree View of the Performance panel
*/
groupByActivity: 'Group by activity',
/**
*@description Text in Timeline Tree View of the Performance panel
*/
groupByCategory: 'Group by category',
/**
*@description Text in Timeline Tree View of the Performance panel
*/
groupByDomain: 'Group by domain',
/**
*@description Text in Timeline Tree View of the Performance panel
*/
groupByFrame: 'Group by frame',
/**
*@description Text in Timeline Tree View of the Performance panel
*/
groupBySubdomain: 'Group by subdomain',
/**
*@description Text in Timeline Tree View of the Performance panel
*/
groupByUrl: 'Group by URL',
/**
*@description Text in Timeline Tree View of the Performance panel
*/
groupByThirdParties: 'Group by Third Parties',
/**
*@description Aria-label for grouping combo box in Timeline Details View
*/
groupBy: 'Group by',
/**
* @description Title of the sidebar pane in the Performance panel which shows the stack (call
* stack) where the program spent the most time (out of all the call stacks) while executing.
*/
heaviestStack: 'Heaviest stack',
/**
* @description Tooltip for the the Heaviest stack sidebar toggle in the Timeline Tree View of the
* Performance panel. Command to open/show the sidebar.
*/
showHeaviestStack: 'Show heaviest stack',
/**
* @description Tooltip for the the Heaviest stack sidebar toggle in the Timeline Tree View of the
* Performance panel. Command to close/hide the sidebar.
*/
hideHeaviestStack: 'Hide heaviest stack',
/**
* @description Screen reader announcement when the heaviest stack sidebar is shown in the Performance panel.
*/
heaviestStackShown: 'Heaviest stack sidebar shown',
/**
* @description Screen reader announcement when the heaviest stack sidebar is hidden in the Performance panel.
*/
heaviestStackHidden: 'Heaviest stack sidebar hidden',
/**
*@description Data grid name for Timeline Stack data grids
*/
timelineStack: 'Timeline stack',
/**
/*@description Text to search by matching case of the input button
*/
matchCase: 'Match case',
/**
*@description Text for searching with regular expression button
*/
useRegularExpression: 'Use regular expression',
/**
* @description Text for Match whole word button
*/
matchWholeWord: 'Match whole word',
/**
* @description Text for bottom up tree button
*/
bottomUp: 'Bottom-up',
/**
* @description Text referring to view bottom up tree
*/
viewBottomUp: 'View Bottom-up',
/**
* @description Text referring to a 1st party entity
*/
firstParty: '1st party',
/**
* @description Text referring to an entity that is an extension
*/
extension: 'Extension',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/timeline/TimelineTreeView.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
/**
* For an overview, read: https://chromium.googlesource.com/devtools/devtools-frontend/+/refs/heads/main/front_end/panels/timeline/README.md#timeline-tree-views
*/
export class TimelineTreeView extends
Common.ObjectWrapper.eventMixin<TimelineTreeView.EventTypes, typeof UI.Widget.VBox>(UI.Widget.VBox)
implements UI.SearchableView.Searchable {
#selectedEvents: Trace.Types.Events.Event[]|null;
private searchResults: Trace.Extras.TraceTree.Node[];
linkifier!: Components.Linkifier.Linkifier;
dataGrid!: DataGrid.SortableDataGrid.SortableDataGrid<GridNode>;
private lastHoveredProfileNode!: Trace.Extras.TraceTree.Node|null;
private textFilterInternal!: TimelineRegExp;
private taskFilter!: Trace.Extras.TraceFilter.ExclusiveNameFilter;
protected startTime!: Trace.Types.Timing.Milli;
protected endTime!: Trace.Types.Timing.Milli;
splitWidget!: UI.SplitWidget.SplitWidget;
detailsView!: UI.Widget.Widget;
private searchableView!: UI.SearchableView.SearchableView;
// TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private currentThreadSetting?: Common.Settings.Setting<any>;
private lastSelectedNodeInternal?: Trace.Extras.TraceTree.Node|null;
private root?: Trace.Extras.TraceTree.Node;
private currentResult?: number;
textFilterUI?: UI.Toolbar.ToolbarInput;
private caseSensitiveButton: UI.Toolbar.ToolbarToggle|undefined;
private regexButton: UI.Toolbar.ToolbarToggle|undefined;
private matchWholeWord: UI.Toolbar.ToolbarToggle|undefined;
#parsedTrace: Trace.Handlers.Types.ParsedTrace|null = null;
#entityMapper: Utils.EntityMapper.EntityMapper|null = null;
#lastHighlightedEvent: HTMLElement|null = null;
eventToTreeNode = new WeakMap<Trace.Types.Events.Event, Trace.Extras.TraceTree.Node>();
/**
* Determines if the first child in the data grid will be selected
* by default when refreshTree() gets called.
*/
protected autoSelectFirstChildOnRefresh = true;
constructor() {
super();
this.#selectedEvents = null;
this.element.classList.add('timeline-tree-view');
this.registerRequiredCSS(timelineTreeViewStyles);
this.searchResults = [];
}
#eventNameForSorting(event: Trace.Types.Events.Event): string {
const name = TimelineUIUtils.eventTitle(event) || event.name;
if (!this.#parsedTrace) {
return name;
}
return name + ':@' + Trace.Handlers.Helpers.getNonResolvedURL(event, this.#parsedTrace);
}
setSearchableView(searchableView: UI.SearchableView.SearchableView): void {
this.searchableView = searchableView;
}
setModelWithEvents(
selectedEvents: Trace.Types.Events.Event[]|null,
parsedTrace: Trace.Handlers.Types.ParsedTrace|null = null,
entityMappings: Utils.EntityMapper.EntityMapper|null = null,
): void {
this.#parsedTrace = parsedTrace;
this.#selectedEvents = selectedEvents;
this.#entityMapper = entityMappings;
this.refreshTree();
}
entityMapper(): Utils.EntityMapper.EntityMapper|null {
return this.#entityMapper;
}
parsedTrace(): Trace.Handlers.Types.ParsedTrace|null {
return this.#parsedTrace;
}
init(): void {
this.linkifier = new Components.Linkifier.Linkifier();
this.taskFilter = new Trace.Extras.TraceFilter.ExclusiveNameFilter([
Trace.Types.Events.Name.RUN_TASK,
]);
this.textFilterInternal = new TimelineRegExp();
this.currentThreadSetting = Common.Settings.Settings.instance().createSetting('timeline-tree-current-thread', 0);
this.currentThreadSetting.addChangeListener(this.refreshTree, this);
const columns = ([] as DataGrid.DataGrid.ColumnDescriptor[]);
this.populateColumns(columns);
this.splitWidget = new UI.SplitWidget.SplitWidget(true, true, 'timeline-tree-view-details-split-widget');
const mainView = new UI.Widget.VBox();
const toolbar = mainView.element.createChild('devtools-toolbar');
toolbar.setAttribute('jslog', `${VisualLogging.toolbar()}`);
toolbar.wrappable = true;
this.populateToolbar(toolbar);
this.dataGrid = new DataGrid.SortableDataGrid.SortableDataGrid({
displayName: i18nString(UIStrings.performance),
columns,
refreshCallback: undefined,
deleteCallback: undefined,
});
this.dataGrid.addEventListener(DataGrid.DataGrid.Events.SORTING_CHANGED, this.sortingChanged, this);
this.dataGrid.element.addEventListener('mousemove', this.onMouseMove.bind(this), true);
this.dataGrid.element.addEventListener(
'mouseleave', () => this.dispatchEventToListeners(TimelineTreeView.Events.TREE_ROW_HOVERED, {node: null}));
this.dataGrid.addEventListener(DataGrid.DataGrid.Events.OPENED_NODE, this.onGridNodeOpened, this);
this.dataGrid.setResizeMethod(DataGrid.DataGrid.ResizeMethod.LAST);
this.dataGrid.setRowContextMenuCallback(this.onContextMenu.bind(this));
this.dataGrid.asWidget().show(mainView.element);
this.dataGrid.addEventListener(DataGrid.DataGrid.Events.SELECTED_NODE, this.updateDetailsForSelection, this);
this.detailsView = new UI.Widget.VBox();
this.detailsView.element.classList.add('timeline-details-view', 'timeline-details-view-body');
this.splitWidget.setMainWidget(mainView);
this.splitWidget.setSidebarWidget(this.detailsView);
this.splitWidget.hideSidebar();
this.splitWidget.show(this.element);
this.splitWidget.addEventListener(UI.SplitWidget.Events.SHOW_MODE_CHANGED, this.onShowModeChanged, this);
}
lastSelectedNode(): Trace.Extras.TraceTree.Node|null|undefined {
return this.lastSelectedNodeInternal;
}
updateContents(selection: TimelineSelection): void {
const timings = rangeForSelection(selection);
const timingMilli = Trace.Helpers.Timing.traceWindowMicroSecondsToMilliSeconds(timings);
this.setRange(timingMilli.min, timingMilli.max);
}
setRange(startTime: Trace.Types.Timing.Milli, endTime: Trace.Types.Timing.Milli): void {
this.startTime = startTime;
this.endTime = endTime;
this.refreshTree();
}
highlightEventInTree(event: Trace.Types.Events.Event|null): void {
// Potentially clear last highlight
const dataGridElem = event && this.dataGridElementForEvent(event);
if (!event || (dataGridElem && dataGridElem !== this.#lastHighlightedEvent)) {
this.#lastHighlightedEvent?.style.setProperty('background-color', '');
}
if (event) {
const rowElem = dataGridElem;
if (rowElem) {
this.#lastHighlightedEvent = rowElem;
this.#lastHighlightedEvent.style.backgroundColor = 'var(--sys-color-yellow-container)';
}
}
}
filters(): Trace.Extras.TraceFilter.TraceFilter[] {
return [this.taskFilter, this.textFilterInternal, ...(ActiveFilters.instance().activeFilters())];
}
filtersWithoutTextFilter(): Trace.Extras.TraceFilter.TraceFilter[] {
return [this.taskFilter, ...(ActiveFilters.instance().activeFilters())];
}
textFilter(): TimelineRegExp {
return this.textFilterInternal;
}
exposePercentages(): boolean {
return false;
}
populateToolbar(toolbar: UI.Toolbar.Toolbar): void {
this.caseSensitiveButton =
new UI.Toolbar.ToolbarToggle(i18nString(UIStrings.matchCase), 'match-case', undefined, 'match-case');
this.caseSensitiveButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, () => {
this.#filterChanged();
}, this);
toolbar.appendToolbarItem(this.caseSensitiveButton);
this.regexButton = new UI.Toolbar.ToolbarToggle(
i18nString(UIStrings.useRegularExpression), 'regular-expression', undefined, 'regular-expression');
this.regexButton.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, () => {
this.#filterChanged();
}, this);
toolbar.appendToolbarItem(this.regexButton);
this.matchWholeWord = new UI.Toolbar.ToolbarToggle(
i18nString(UIStrings.matchWholeWord), 'match-whole-word', undefined, 'match-whole-word');
this.matchWholeWord.addEventListener(UI.Toolbar.ToolbarButton.Events.CLICK, () => {
this.#filterChanged();
}, this);
toolbar.appendToolbarItem(this.matchWholeWord);
const textFilterUI = new UI.Toolbar.ToolbarFilter();
this.textFilterUI = textFilterUI;
textFilterUI.addEventListener(UI.Toolbar.ToolbarInput.Event.TEXT_CHANGED, this.#filterChanged, this);
toolbar.appendToolbarItem(textFilterUI);
}
selectedEvents(): Trace.Types.Events.Event[] {
// TODO: can we make this type readonly?
return this.#selectedEvents || [];
}
appendContextMenuItems(_contextMenu: UI.ContextMenu.ContextMenu, _node: Trace.Extras.TraceTree.Node): void {
}
// TODO(paulirish): rename profileNode to treeNode
selectProfileNode(treeNode: Trace.Extras.TraceTree.Node, suppressSelectedEvent: boolean): void {
const pathToRoot: Trace.Extras.TraceTree.Node[] = [];
let node: (Trace.Extras.TraceTree.Node|null)|Trace.Extras.TraceTree.Node = treeNode;
for (; node; node = node.parent) {
pathToRoot.push(node);
}
for (let i = pathToRoot.length - 1; i > 0; --i) {
const gridNode = this.dataGridNodeForTreeNode(pathToRoot[i]);
if (gridNode?.dataGrid) {
gridNode.expand();
}
}
const gridNode = this.dataGridNodeForTreeNode(treeNode);
if (gridNode?.dataGrid) {
gridNode.reveal();
gridNode.select(suppressSelectedEvent);
}
}
refreshTree(): void {
if (!this.element.parentElement) {
// This function can be called in different views (Bottom-Up and
// Call Tree) by the same single event whenever the group-by
// dropdown changes value. Thus, we bail out whenever the view is
// not visible, which we know if the related element is detached
// from the document.
return;
}
this.linkifier.reset();
this.dataGrid.rootNode().removeChildren();
if (!this.#parsedTrace) {
this.updateDetailsForSelection();
return;
}
this.root = this.buildTree();
const children = this.root.children();
let maxSelfTime = 0;
let maxTotalTime = 0;
const totalUsedTime = this.root.totalTime - this.root.selfTime;
for (const child of children.values()) {
maxSelfTime = Math.max(maxSelfTime, child.selfTime);
maxTotalTime = Math.max(maxTotalTime, child.totalTime);
}
for (const child of children.values()) {
// Exclude the idle time off the total calculation.
const gridNode = new TreeGridNode(child, totalUsedTime, maxSelfTime, maxTotalTime, this);
for (const e of child.events) {
this.eventToTreeNode.set(e, child);
}
this.dataGrid.insertChild(gridNode);
}
this.sortingChanged();
this.updateDetailsForSelection();
if (this.searchableView) {
this.searchableView.refreshSearch();
}
const rootNode = this.dataGrid.rootNode();
if (this.autoSelectFirstChildOnRefresh && rootNode.children.length > 0) {
rootNode.children[0].select(/* supressSelectedEvent */ true);
}
}
buildTree(): Trace.Extras.TraceTree.Node {
throw new Error('Not Implemented');
}
buildTopDownTree(doNotAggregate: boolean, eventGroupIdCallback: ((arg0: Trace.Types.Events.Event) => string)|null):
Trace.Extras.TraceTree.Node {
return new Trace.Extras.TraceTree.TopDownRootNode(this.selectedEvents(), {
filters: this.filters(),
startTime: this.startTime,
endTime: this.endTime,
doNotAggregate,
eventGroupIdCallback,
});
}
populateColumns(columns: DataGrid.DataGrid.ColumnDescriptor[]): void {
columns.push(
({id: 'self', title: i18nString(UIStrings.selfTime), width: '120px', fixedWidth: true, sortable: true} as
DataGrid.DataGrid.ColumnDescriptor));
columns.push(
({id: 'total', title: i18nString(UIStrings.totalTime), width: '120px', fixedWidth: true, sortable: true} as
DataGrid.DataGrid.ColumnDescriptor));
columns.push(
({id: 'activity', title: i18nString(UIStrings.activity), disclosure: true, sortable: true} as
DataGrid.DataGrid.ColumnDescriptor));
}
sortingChanged(): void {
const columnId = this.dataGrid.sortColumnId();
if (!columnId) {
return;
}
const sortFunction = this.getSortingFunction(columnId);
if (sortFunction) {
this.dataGrid.sortNodes(sortFunction, !this.dataGrid.isSortOrderAscending());
}
}
// Gets the sorting function for the tree view nodes.
getSortingFunction(columnId: string):
((a: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>,
b: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>) => number)|null {
const compareNameSortFn =
(a: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>,
b: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>): number => {
const nodeA = (a as TreeGridNode);
const nodeB = (b as TreeGridNode);
const eventA = nodeA.profileNode.event;
const eventB = nodeB.profileNode.event;
if (!eventA || !eventB) {
return 0;
}
const nameA = this.#eventNameForSorting(eventA);
const nameB = this.#eventNameForSorting(eventB);
return nameA.localeCompare(nameB);
};
switch (columnId) {
case 'start-time':
return compareStartTime;
case 'self':
return compareSelfTime;
case 'total':
return compareTotalTime;
case 'activity':
case 'site':
return compareNameSortFn;
default:
console.assert(false, 'Unknown sort field: ' + columnId);
return null;
}
function compareSelfTime(
a: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>,
b: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>): number {
const nodeA = a as TreeGridNode;
const nodeB = b as TreeGridNode;
return nodeA.profileNode.selfTime - nodeB.profileNode.selfTime;
}
function compareStartTime(
a: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>,
b: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>): number {
const nodeA = (a as TreeGridNode);
const nodeB = (b as TreeGridNode);
const eventA = nodeA.profileNode.event;
const eventB = nodeB.profileNode.event;
// Should not happen, but guard against the nodes not having events.
if (!eventA || !eventB) {
return 0;
}
return eventA.ts - eventB.ts;
}
function compareTotalTime(
a: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>,
b: DataGrid.SortableDataGrid.SortableDataGridNode<GridNode>): number {
const nodeA = a as TreeGridNode;
const nodeB = b as TreeGridNode;
return nodeA.profileNode.totalTime - nodeB.profileNode.totalTime;
}
}
#filterChanged(): void {
const searchQuery = this.textFilterUI?.value();
const caseSensitive = this.caseSensitiveButton?.isToggled() ?? false;
const isRegex = this.regexButton?.isToggled() ?? false;
const matchWholeWord = this.matchWholeWord?.isToggled() ?? false;
this.textFilterInternal.setRegExp(
searchQuery ? Platform.StringUtilities.createSearchRegex(searchQuery, caseSensitive, isRegex, matchWholeWord) :
null);
this.refreshTree();
}
private onShowModeChanged(): void {
if (this.splitWidget.showMode() === UI.SplitWidget.ShowMode.ONLY_MAIN) {
return;
}
this.lastSelectedNodeInternal = undefined;
this.updateDetailsForSelection();
}
protected updateDetailsForSelection(): void {
const selectedNode = this.dataGrid.selectedNode ? (this.dataGrid.selectedNode as TreeGridNode).profileNode : null;
if (selectedNode === this.lastSelectedNodeInternal) {
return;
}
if (this.splitWidget.showMode() === UI.SplitWidget.ShowMode.ONLY_MAIN) {
return;
}
this.detailsView.detachChildWidgets();
this.detailsView.element.removeChildren();
this.lastSelectedNodeInternal = selectedNode;
if (selectedNode && this.showDetailsForNode(selectedNode)) {
return;
}
const banner = this.detailsView.element.createChild('div', 'full-widget-dimmed-banner');
UI.UIUtils.createTextChild(banner, i18nString(UIStrings.selectItemForDetails));
}
showDetailsForNode(_node: Trace.Extras.TraceTree.Node): boolean {
return false;
}
private onMouseMove(event: Event): void {
const gridNode =
event.target && (event.target instanceof Node) ? (this.dataGrid.dataGridNodeFromNode((event.target))) : null;
const profileNode = (gridNode as TreeGridNode)?.profileNode;
if (profileNode === this.lastHoveredProfileNode) {
return;
}
this.lastHoveredProfileNode = profileNode;
this.onHover(profileNode);
}
onHover(node: Trace.Extras.TraceTree.Node|null): void {
this.dispatchEventToListeners(TimelineTreeView.Events.TREE_ROW_HOVERED, {node});
}
onClick(node: Trace.Extras.TraceTree.Node|null): void {
this.dispatchEventToListeners(TimelineTreeView.Events.TREE_ROW_CLICKED, {node});
}
override wasShown(): void {
this.dataGrid.addEventListener(DataGrid.DataGrid.Events.SELECTED_NODE, this.#onDataGridSelectionChange, this);
this.dataGrid.addEventListener(DataGrid.DataGrid.Events.DESELECTED_NODE, this.#onDataGridDeselection, this);
}
override childWasDetached(_widget: UI.Widget.Widget): void {
this.dataGrid.removeEventListener(DataGrid.DataGrid.Events.SELECTED_NODE, this.#onDataGridSelectionChange);
this.dataGrid.removeEventListener(DataGrid.DataGrid.Events.DESELECTED_NODE, this.#onDataGridDeselection);
}
/**
* This event fires when the user selects a row in the grid, either by
* clicking or by using the arrow keys. We want to have the same effect as
* when the user hover overs a row.
*/
#onDataGridSelectionChange(event: Common.EventTarget.EventTargetEvent<DataGrid.DataGrid.DataGridNode<GridNode>>):
void {
this.onClick((event.data as GridNode).profileNode);
this.onHover((event.data as GridNode).profileNode);
}
/**
* Called when the user deselects a row.
* This can either be because they have selected a new row
* (you should expect a SELECTED_NODE event after this one)
* or because they have deselected without a new selection.
*/
#onDataGridDeselection(): void {
this.onClick(null);
this.onHover(null);
}
onGridNodeOpened(): void {
const gridNode = this.dataGrid.selectedNode as TreeGridNode;
// Use tree's hover method in case of unique hover experiences (like ThirdPartyTree).
this.onHover(gridNode.profileNode);
this.updateDetailsForSelection();
}
private onContextMenu(
contextMenu: UI.ContextMenu.ContextMenu, eventGridNode: DataGrid.DataGrid.DataGridNode<GridNode>): void {
const gridNode = (eventGridNode as GridNode);
if (gridNode.linkElement) {
contextMenu.appendApplicableItems(gridNode.linkElement);
}
const profileNode = gridNode.profileNode;
if (profileNode) {
this.appendContextMenuItems(contextMenu, profileNode);
}
}
dataGridElementForEvent(event: Trace.Types.Events.Event|null): HTMLElement|null {
if (!event) {
return null;
}
const treeNode = this.eventToTreeNode.get(event);
return (treeNode && this.dataGridNodeForTreeNode(treeNode)?.element()) ?? null;
}
dataGridNodeForTreeNode(treeNode: Trace.Extras.TraceTree.Node): GridNode|null {
return treeNodeToGridNode.get(treeNode) || null;
}
// UI.SearchableView.Searchable implementation
onSearchCanceled(): void {
this.searchResults = [];
this.currentResult = 0;
}
performSearch(searchConfig: UI.SearchableView.SearchConfig, _shouldJump: boolean, _jumpBackwards?: boolean): void {
this.searchResults = [];
this.currentResult = 0;
if (!this.root) {
return;
}
const searchRegex = searchConfig.toSearchRegex();
this.searchResults = this.root.searchTree(
event => TimelineUIUtils.testContentMatching(event, searchRegex.regex, this.#parsedTrace || undefined));
this.searchableView.updateSearchMatchesCount(this.searchResults.length);
}
jumpToNextSearchResult(): void {
if (!this.searchResults.length || this.currentResult === undefined) {
return;
}
this.selectProfileNode(this.searchResults[this.currentResult], false);
this.currentResult = Platform.NumberUtilities.mod(this.currentResult + 1, this.searchResults.length);
}
jumpToPreviousSearchResult(): void {
if (!this.searchResults.length || this.currentResult === undefined) {
return;
}
this.selectProfileNode(this.searchResults[this.currentResult], false);
this.currentResult = Platform.NumberUtilities.mod(this.currentResult - 1, this.searchResults.length);
}
supportsCaseSensitiveSearch(): boolean {
return true;
}
supportsRegexSearch(): boolean {
return true;
}
}
export namespace TimelineTreeView {
export const enum Events {
TREE_ROW_HOVERED = 'TreeRowHovered',
BOTTOM_UP_BUTTON_CLICKED = 'BottomUpButtonClicked',
TREE_ROW_CLICKED = 'TreeRowClicked',
}
export interface EventTypes {
[Events.TREE_ROW_HOVERED]: {node: Trace.Extras.TraceTree.Node|null, events?: Trace.Types.Events.Event[]};
[Events.BOTTOM_UP_BUTTON_CLICKED]: Trace.Extras.TraceTree.Node|null;
[Events.TREE_ROW_CLICKED]: {node: Trace.Extras.TraceTree.Node|null, events?: Trace.Types.Events.Event[]};
}
}
/**
* GridNodes are 1:1 with `TraceTree.Node`s but represent them within the DataGrid. It handles the representation as a row.
* `TreeGridNode` extends this to maintain relationship to the tree, and handles populate().
*
* `TimelineStackView` (aka heaviest stack) uses GridNode directly (as there's no hierarchy there), otherwise these TreeGridNode could probably be consolidated.
*/
export class GridNode extends DataGrid.SortableDataGrid.SortableDataGridNode<GridNode> {
protected populated: boolean;
profileNode: Trace.Extras.TraceTree.Node;
protected treeView: TimelineTreeView;
protected grandTotalTime: number;
protected maxSelfTime: number;
protected maxTotalTime: number;
linkElement: Element|null;
constructor(
profileNode: Trace.Extras.TraceTree.Node, grandTotalTime: number, maxSelfTime: number, maxTotalTime: number,
treeView: TimelineTreeView) {
super(null, false);
this.populated = false;
this.profileNode = profileNode;
this.treeView = treeView;
this.grandTotalTime = grandTotalTime;
this.maxSelfTime = maxSelfTime;
this.maxTotalTime = maxTotalTime;
this.linkElement = null;
}
override createCell(columnId: string): HTMLElement {
if (columnId === 'activity' || columnId === 'site') {
return this.createNameCell(columnId);
}
return this.createValueCell(columnId) || super.createCell(columnId);
}
private createNameCell(columnId: string): HTMLElement {
const cell = this.createTD(columnId);
const container = cell.createChild('div', 'name-container');
const iconContainer = container.createChild('div', 'activity-icon-container');
const icon = iconContainer.createChild('div', 'activity-icon');
const name = container.createChild('div', 'activity-name');
const event = this.profileNode.event;
if (this.profileNode.isGroupNode()) {
const treeView = (this.treeView as AggregatedTimelineTreeView);
const info = treeView.displayInfoForGroupNode(this.profileNode);
name.textContent = info.name;
icon.style.backgroundColor = info.color;
if (info.icon) {
iconContainer.insertBefore(info.icon, icon);
}
// Include badges with the name, if relevant.
if (columnId === 'site' && this.treeView instanceof ThirdPartyTreeView.ThirdPartyTreeViewWidget) {
const thirdPartyTree = this.treeView;
let badgeText = '';
if (thirdPartyTree.nodeIsFirstParty(this.profileNode)) {
badgeText = i18nString(UIStrings.firstParty);
} else if (thirdPartyTree.nodeIsExtension(this.profileNode)) {
badgeText = i18nString(UIStrings.extension);
}
if (badgeText) {
const badge = container.createChild('div', 'entity-badge');
badge.textContent = badgeText;
UI.ARIAUtils.setLabel(badge, badgeText);
}
}
} else if (event) {
name.textContent = TimelineUIUtils.eventTitle(event);
const parsedTrace = this.treeView.parsedTrace();
const target = parsedTrace ? targetForEvent(parsedTrace, event) : null;
const linkifier = this.treeView.linkifier;
const isFreshRecording = Boolean(parsedTrace && Tracker.instance().recordingIsFresh(parsedTrace));
this.linkElement = TimelineUIUtils.linkifyTopCallFrame(event, target, linkifier, isFreshRecording);
if (this.linkElement) {
container.createChild('div', 'activity-link').appendChild(this.linkElement);
}
UI.ARIAUtils.setLabel(icon, TimelineUIUtils.eventStyle(event).category.title);
icon.style.backgroundColor = TimelineUIUtils.eventColor(event);
if (Trace.Types.Extensions.isSyntheticExtensionEntry(event)) {
icon.style.backgroundColor = Extensions.ExtensionUI.extensionEntryColor(event);
}
}
return cell;
}
private createValueCell(columnId: string): HTMLElement|null {
if (columnId !== 'self' && columnId !== 'total' && columnId !== 'start-time' && columnId !== 'transfer-size') {
return null;
}
let showPercents = false;
let value: number;
let maxTime: number|undefined;
let event: Trace.Types.Events.Event|null;
let isSize = false;
let showBottomUpButton = false;
const thirdPartyView = this.treeView;
switch (columnId) {
case 'start-time': {
event = this.profileNode.event;
const parsedTrace = this.treeView.parsedTrace();
if (!parsedTrace) {
throw new Error('Unable to load trace data for tree view');
}
const timings = event && Trace.Helpers.Timing.eventTimingsMilliSeconds(event);
const startTime = timings?.startTime ?? 0;
value = startTime - Trace.Helpers.Timing.microToMilli(parsedTrace.Meta.traceBounds.min);
} break;
case 'self':
value = this.profileNode.selfTime;
maxTime = this.maxSelfTime;
showPercents = true;
showBottomUpButton = thirdPartyView instanceof ThirdPartyTreeView.ThirdPartyTreeViewWidget;
break;
case 'total':
value = this.profileNode.totalTime;
maxTime = this.maxTotalTime;
showPercents = true;
break;
case 'transfer-size':
value = this.profileNode.transferSize;
isSize = true;
break;
default:
return null;
}
const cell = this.createTD(columnId);
cell.className = 'numeric-column';
let textDiv;
if (!isSize) {
cell.setAttribute('title', i18n.TimeUtilities.preciseMillisToString(value, 4));
textDiv = cell.createChild('div');
textDiv.createChild('span').textContent = i18n.TimeUtilities.preciseMillisToString(value, 1);
} else {
cell.setAttribute('title', i18n.ByteUtilities.formatBytesToKb(value));
textDiv = cell.createChild('div');
textDiv.createChild('span').textContent = i18n.ByteUtilities.formatBytesToKb(value);
}
if (showPercents && this.treeView.exposePercentages()) {
textDiv.createChild('span', 'percent-column').textContent =
i18nString(UIStrings.percentPlaceholder, {PH1: (value / this.grandTotalTime * 100).toFixed(1)});
}
if (maxTime) {
textDiv.classList.add('background-bar-text');
cell.createChild('div', 'background-bar-container').createChild('div', 'background-bar').style.width =
(value * 100 / maxTime).toFixed(1) + '%';
}
// Generate button on hover for 3P self time cell.
if (showBottomUpButton) {
this.generateBottomUpButton(textDiv);
}
return cell;
}
// Generates bottom up tree hover button and appends it to the provided cell element.
private generateBottomUpButton(textDiv: HTMLElement): void {
const button = new Buttons.Button.Button();
button.data = {
variant: Buttons.Button.Variant.ICON,
iconName: 'account-tree',
size: Buttons.Button.Size.SMALL,
toggledIconName: i18nString(UIStrings.bottomUp),
};
UI.ARIAUtils.setLabel(button, i18nString(UIStrings.viewBottomUp));
button.addEventListener('click', () => this.#bottomUpButtonClicked());
UI.Tooltip.Tooltip.install(button, i18nString(UIStrings.bottomUp));
// Append the button to the last column
textDiv.appendChild(button);
}
#bottomUpButtonClicked(): void {
// We should also trigger an event to "unhover" the 3P tree row. Since this isn't
// triggered when clicking the bottom up button.
this.treeView.dispatchEventToListeners(TimelineTreeView.Events.TREE_ROW_HOVERED, {node: null});
this.treeView.dispatchEventToListeners(TimelineTreeView.Events.BOTTOM_UP_BUTTON_CLICKED, this.profileNode);
}
}
/**
* `TreeGridNode` lets a `GridNode` (row) populate based on its tree children.
*/
export class TreeGridNode extends GridNode {
constructor(
profileNode: Trace.Extras.TraceTree.Node, grandTotalTime: number, maxSelfTime: number, maxTotalTime: number,
treeView: TimelineTreeView) {
super(profileNode, grandTotalTime, maxSelfTime, maxTotalTime, treeView);
this.setHasChildren(this.profileNode.hasChildren());
treeNodeToGridNode.set(profileNode, this);
}
override populate(): void {
if (this.populated) {
return;
}
this.populated = true;
if (!this.profileNode.children) {
return;
}
for (const node of this.profileNode.children().values()) {
const gridNode = new TreeGridNode(node, this.grandTotalTime, this.maxSelfTime, this.maxTotalTime, this.treeView);
for (const e of node.events) {
this.treeView.eventToTreeNode.set(e, node);
}
this.insertChildOrdered(gridNode);
}
}
}
const treeNodeToGridNode = new WeakMap<Trace.Extras.TraceTree.Node, TreeGridNode>();
export class AggregatedTimelineTreeView extends TimelineTreeView {
protected readonly groupBySetting: Common.Settings.Setting<AggregatedTimelineTreeView.GroupBy>;
readonly stackView: TimelineStackView;
constructor() {
super();
this.groupBySetting = Common.Settings.Settings.instance().createSetting(
'timeline-tree-group-by', AggregatedTimelineTreeView.GroupBy.None);
this.groupBySetting.addChangeListener(this.refreshTree.bind(this));
this.init();
this.stackView = new TimelineStackView(this);
this.stackView.addEventListener(TimelineStackView.Events.SELECTION_CHANGED, this.onStackViewSelectionChanged, this);
}
setGroupBySetting(groupBy: AggregatedTimelineTreeView.GroupBy): void {
this.groupBySetting.set(groupBy);
}
override updateContents(selection: TimelineSelection): void {
super.updateContents(selection);
const rootNode = this.dataGrid.rootNode();
if (rootNode.children.length) {
rootNode.children[0].select(/* suppressSelectedEvent */ true);
}
this.updateDetailsForSelection();
}
private beautifyDomainName(this: AggregatedTimelineTreeView, name: string, node: Trace.Extras.TraceTree.Node):
string {
if (AggregatedTimelineTreeView.isExtensionInternalURL(name as Platform.DevToolsPath.UrlString)) {
name = i18nString(UIStrings.chromeExtensionsOverhead);
} else if (AggregatedTimelineTreeView.isV8NativeURL(name as Platform.DevToolsPath.UrlString)) {
name = i18nString(UIStrings.vRuntime);
} else if (name.startsWith('chrome-extension')) {
name = this.entityMapper()?.entityForEvent(node.event)?.name || name;
}
return name;
}
displayInfoForGroupNode(node: Trace.Extras.TraceTree.Node): {
name: string,
color: string,
icon: (Element|undefined),
} {
const categories = Utils.EntryStyles.getCategoryStyles();
const color = TimelineUIUtils.eventColor(node.event);
const unattributed = i18nString(UIStrings.unattributed);
const id = typeof node.id === 'symbol' ? undefined : node.id;
switch (this.groupBySetting.get()) {
case AggregatedTimelineTreeView.GroupBy.Category: {
const idIsValid = id && Utils.EntryStyles.stringIsEventCategory(id);
const category = idIsValid ? categories[id] || categories['other'] : {title: unattributed, color: unattributed};
return {name: category.title, color: category.color, icon: undefined};
}
case AggregatedTimelineTreeView.GroupBy.Domain:
case AggregatedTimelineTreeView.GroupBy.Subdomain:
case AggregatedTimelineTreeView.GroupBy.ThirdParties: {
// This `undefined` is [unattributed]
// TODO(paulirish,aixba): Improve attribution to reduce amount of items in [unattributed].
const domainName = id ? this.beautifyDomainName(id, node) : undefined;
return {name: domainName || unattributed, color, icon: undefined};
}
case AggregatedTimelineTreeView.GroupBy.EventName: {
if (!node.event) {
throw new Error('Unable to find event for group by operation');
}
const name = TimelineUIUtils.eventTitle(node.event);
return {
name,
color,
icon: undefined,
};
}
case AggregatedTimelineTreeView.GroupBy.URL:
break;
case AggregatedTimelineTreeView.GroupBy.Frame: {
const frame = id ? this.parsedTrace()?.PageFrames.frames.get(id) : undefined;
const frameName = frame ? TimelineUIUtils.displayNameForFrame(frame) : i18nString(UIStrings.page);
return {name: frameName, color, icon: undefined};
}
default:
console.assert(false, 'Unexpected grouping type');
}
return {name: id || unattributed, color, icon: undefined};
}
override populateToolbar(toolbar: UI.Toolbar.Toolbar): void {
super.populateToolbar(toolbar);
const groupBy = AggregatedTimelineTreeView.GroupBy;
const options = [
{label: i18nString(UIStrings.noGrouping), value: groupBy.None},
{label: i18nString(UIStrings.groupByActivity), value: groupBy.EventName},
{label: i18nString(UIStrings.groupByCategory), value: groupBy.Category},
{label: i18nString(UIStrings.groupByDomain), value: groupBy.Domain},
{label: i18nString(UIStrings.groupByFrame), value: groupBy.Frame},
{label: i18nString(UIStrings.groupBySubdomain), value: groupBy.Subdomain},
{label: i18nString(UIStrings.groupByUrl), value: groupBy.URL},
{label: i18nString(UIStrings.groupByThirdParties), value: groupBy.ThirdParties},
];
toolbar.appendToolbarItem(
new UI.Toolbar.ToolbarSettingComboBox(options, this.groupBySetting, i18nString(UIStrings.groupBy)));
toolbar.appendSpacer();
toolbar.appendToolbarItem(this.splitWidget.createShowHideSidebarButton(
i18nString(UIStrings.showHeaviestStack), i18nString(UIStrings.hideHeaviestStack),
i18nString(UIStrings.heaviestStackShown), i18nString(UIStrings.heaviestStackHidden)));
}
private buildHeaviestStack(treeNode: Trace.Extras.TraceTree.Node): Trace.Extras.TraceTree.Node[] {
console.assert(Boolean(treeNode.parent), 'Attempt to build stack for tree root');
let result: Trace.Extras.TraceTree.Node[] = [];
// Do not add root to the stack, as it's the tree itself.
for (let node: Trace.Extras.TraceTree.Node = treeNode; node?.parent; node = node.parent) {
result.push(node);
}
result = result.reverse();
for (let node: Trace.Extras.TraceTree.Node = treeNode; node?.children()?.size;) {
const children = Array.from(node.children().values());
node = children.reduce((a, b) => a.totalTime > b.totalTime ? a : b);
result.push(node);
}
return result;
}
override exposePercentages(): boolean {
return true;
}
private onStackViewSelectionChanged(): void {
const treeNode = this.stackView.selectedTreeNode();
if (treeNode) {
this.selectProfileNode(treeNode, true);
}
}
override showDetailsForNode(node: Trace.Extras.TraceTree.Node): boolean {
const stack = this.buildHeaviestStack(node);
this.stackView.setStack(stack, node);
this.stackView.show(this.detailsView.element);
return true;
}
protected groupingFunction(groupBy: AggregatedTimelineTreeView.GroupBy):
((arg0: Trace.Types.Events.Event) => string)|null {
const GroupBy = AggregatedTimelineTreeView.GroupBy;
switch (groupBy) {
case GroupBy.None:
return null;
case GroupBy.EventName:
return (event: Trace.Types.Events.Event) => TimelineUIUtils.eventStyle(event).title;
case GroupBy.Category:
return (event: Trace.Types.Events.Event) => TimelineUIUtils.eventStyle(event).category.name;
case GroupBy.Subdomain:
case GroupBy.Domain:
case GroupBy.ThirdParties:
return this.domainByEvent.bind(this, groupBy);
case GroupBy.URL:
return (event: Trace.Types.Events.Event) => {
const parsedTrace = this.parsedTrace();
return parsedTrace ? Trace.Handlers.Helpers.getNonResolvedURL(event, parsedTrace) ?? '' : '';
};
case GroupBy.Frame:
return (event: Trace.Types.Events.Event) => {
const frameId = Trace.Helpers.Trace.frameIDForEvent(event);
return frameId || this.parsedTrace()?.Meta.mainFrameId || '';
};
default:
console.assert(false, `Unexpected aggregation setting: ${groupBy}`);
return null;
}
}
// This is our groupingFunction that returns the eventId in Domain, Subdomain, and ThirdParty groupBy scenarios.
// The eventid == the identity of a node that we expect in a bottomUp tree (either without grouping or with the groupBy grouping)
// A "top node" (in `ungroupedTopNodes`) is aggregated by this. (But so are all the other nodes, except the `GroupNode`s)
private domainByEvent(groupBy: AggregatedTimelineTreeView.GroupBy, event: Trace.Types.Events.Event): string {
const parsedTrace = this.parsedTrace();
if (!parsedTrace) {
return '';
}
const url = Trace.Handlers.Helpers.getNonResolvedURL(event, parsedTrace);
if (!url) {
// We could have receiveDataEvents (that don't have a url), but that have been
// attributed to an entity, let's check for these. This is used for ThirdParty grouping.
const entity = this.entityMapper()?.entityForEvent(event);
if (groupBy === AggregatedTimelineTreeView.GroupBy.ThirdParties && entity) {
if (!entity) {
return '';
}
const firstDomain = entity.domains[0];
const parsedURL = Common.ParsedURL.ParsedURL.fromString(firstDomain);
// chrome-extension check must come before entity.name.
if (parsedURL?.scheme === 'chrome-extension') {
return `${parsedURL.scheme}://${parsedURL.host}`;
}
return entity.name;
}
return '';
}
if (AggregatedTimelineTreeView.isExtensionInternalURL(url)) {
return AggregatedTimelineTreeView.extensionInternalPrefix;
}
if (AggregatedTimelineTreeView.isV8NativeURL(url)) {
return AggregatedTimelineTreeView.v8NativePrefix;
}
const parsedURL = Common.ParsedURL.ParsedURL.fromString(url);
if (!parsedURL) {
return '';
}
if (parsedURL.scheme === 'chrome-extension') {
return parsedURL.scheme + '://' + parsedURL.host;
}
// This must follow after the extension checks.
if (groupBy === AggregatedTimelineTreeView.GroupBy.ThirdParties) {
const entity = this.entityMapper()?.entityForEvent(event);
if (!entity) {
return '';
}
return entity.name;
}
if (groupBy === AggregatedTimelineTreeView.GroupBy.Subdomain) {
return parsedURL.host;
}
if (/^[.0-9]+$/.test(parsedURL.host)) {
return parsedURL.host;
}
const domainMatch = /([^.]*\.)?[^.]*$/.exec(parsedURL.host);
return domainMatch?.[0] || '';
}
private static isExtensionInternalURL(url: Platform.DevToolsPath.UrlString): boolean {
return url.startsWith(AggregatedTimelineTreeView.extensionInternalPrefix);
}
private static isV8NativeURL(url: Platform.DevToolsPath.UrlString): boolean {
return url.startsWith(AggregatedTimelineTreeView.v8NativePrefix);
}
private static readonly extensionInternalPrefix = 'extensions::';
private static readonly v8NativePrefix = 'native ';
override onHover(node: Trace.Extras.TraceTree.Node|null): void {
if (node !== null && this.groupBySetting.get() === AggregatedTimelineTreeView.GroupBy.ThirdParties) {
const events = this.#getThirdPartyEventsForNode(node);
this.dispatchEventToListeners(TimelineTreeView.Events.TREE_ROW_HOVERED, {node, events});
return;
}
this.dispatchEventToListeners(TimelineTreeView.Events.TREE_ROW_HOVERED, {node});
}
override onClick(node: Trace.Extras.TraceTree.Node|null): void {
if (node !== null && this.groupBySetting.get() === AggregatedTimelineTreeView.GroupBy.ThirdParties) {
const events = this.#getThirdPartyEventsForNode(node);
this.dispatchEventToListeners(TimelineTreeView.Events.TREE_ROW_CLICKED, {node, events});
return;
}
this.dispatchEventToListeners(TimelineTreeView.Events.TREE_ROW_CLICKED, {node});
}
#getThirdPartyEventsForNode(node: Trace.Extras.TraceTree.Node): Trace.Types.Events.Event[]|undefined {
if (!node.event) {
return;
}
const entity = this.entityMapper()?.entityForEvent(node.event);
// Should be [unattributed]. Just use the node's events.
if (!entity) {
return node.events;
}
const events = this.entityMapper()?.eventsForEntity(entity);
return events;
}
}
export namespace AggregatedTimelineTreeView {
export enum GroupBy {
/* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */
None = 'None',
EventName = 'EventName',
Category = 'Category',
Domain = 'Domain',
Subdomain = 'Subdomain',
URL = 'URL',
Frame = 'Frame',
ThirdParties = 'ThirdParties',
/* eslint-enable @typescript-eslint/naming-convention */
}
}
export class CallTreeTimelineTreeView extends AggregatedTimelineTreeView {
constructor() {
super();
this.element.setAttribute('jslog', `${VisualLogging.pane('call-tree').track({resize: true})}`);
this.dataGrid.markColumnAsSortedBy('total', DataGrid.DataGrid.Order.Descending);
}
override buildTree(): Trace.Extras.TraceTree.Node {
const grouping = this.groupBySetting.get();
return this.buildTopDownTree(false, this.groupingFunction(grouping));
}
}
export class BottomUpTimelineTreeView extends AggregatedTimelineTreeView {
constructor() {
super();
this.element.setAttribute('jslog', `${VisualLogging.pane('bottom-up').track({resize: true})}`);
this.dataGrid.markColumnAsSortedBy('self', DataGrid.DataGrid.Order.Descending);
}
override buildTree(): Trace.Extras.TraceTree.Node {
return new Trace.Extras.TraceTree.BottomUpRootNode(this.selectedEvents(), {
textFilter: this.textFilter(),
filters: this.filtersWithoutTextFilter(),
startTime: this.startTime,
endTime: this.endTime,
eventGroupIdCallback: this.groupingFunction(this.groupBySetting.get()),
// To include instant events. When this is set to true, instant events are
// considered (to calculate transfer size). This then includes these events in tree nodes.
calculateTransferSize: true,
// We should forceGroupIdCallback if filtering by 3P for correct 3P grouping.
forceGroupIdCallback: this.groupBySetting.get() === AggregatedTimelineTreeView.GroupBy.ThirdParties,
});
}
}
export class TimelineStackView extends
Common.ObjectWrapper.eventMixin<TimelineStackView.EventTypes, typeof UI.Widget.VBox>(UI.Widget.VBox) {
private readonly treeView: TimelineTreeView;
private readonly dataGrid: DataGrid.ViewportDataGrid.ViewportDataGrid<unknown>;
constructor(treeView: TimelineTreeView) {
super();
const hea